Skip to main content

Drive the Hermes Agent from any Python app over the Agent Client Protocol (ACP) — streaming events, your own tools, safe by default. No Jupyter required.

Project description

hermes-acp-sdk

Drive the Hermes Agent from any Python app — a CLI, a backend service, a bot — over the Agent Client Protocol (ACP). No Jupyter, no notebook kernel, no editor. The SDK spawns hermes acp as a subprocess, speaks the wire protocol for you, and hands back a clean async iterator of typed events.

You also get to hand the agent your own Python functions as tools — running inside your process, with full access to your application's state.

"Simplicity is prerequisite for reliability." — Edsger W. Dijkstra

ACP is a two-way protocol with a large callback surface and a couple of traps that are documented nowhere. This SDK absorbs all of it: ~10 lines instead of ~300.

Features

Feature What it does
HermesClient() Spawns hermes acp, does the handshake, cleans the subprocess up
session() Opens a session and selects a model — the trap everyone hits (see below)
prompt()async for Streaming becomes an ordinary loop over typed events
Agent thoughts AgentThought — watch the model reason, not just answer
Tool calls & plans ToolCall, PlanUpdated, Usage events, all typed
tools=[your_function] Give the agent your Python functions via an in-process MCP server
Profiles Use existing profiles or create new ones — isolated memory, skills, sessions
clone_provider New profile inherits provider credentials from default — no API keys needed
Deny-by-default Permissions refused, filesystem off, terminals off — until you opt in
No secret leakage An agent-spawned command never inherits your env (DEEPSEEK_API_KEY, …)
Finds hermes itself On PATH or next to the running interpreter (venv, not activated)
Fully typed py.typed, frozen dataclasses, 97 tests + real-Hermes integration tests

See it working

  • demo.ipynb — run against a real Hermes and committed with its output saved, so you can read the real streamed answers, the agent's thoughts, the security model and the MCP tools without running anything or spending a token.

  • examples/chat.py — a streaming chat REPL in a plain terminal (~30 lines). This is the whole thesis: no Jupyter required.

    python examples/chat.py --thoughts
    

Install

pip install hermes-acp-sdk                 # the SDK
pip install "hermes-agent[acp]"            # the agent it talks to (note the [acp] extra!)
export DEEPSEEK_API_KEY="sk-..."           # or any provider Hermes supports

Optional, to expose your own functions as tools:

pip install "hermes-acp-sdk[tools]"

⚠️ Python version — read this first

hermes-agent requires Python >=3.11,<3.14. On 3.14 it will not install at all, and the symptom is baffling: ModuleNotFoundError: No module named 'acp', or a missing hermes binary. (The SDK itself runs on 3.10+, but it is useless without a Hermes to talk to.)

python3.13 -m venv .venv
./.venv/bin/pip install hermes-acp-sdk "hermes-agent[acp]"
./.venv/bin/python your_app.py        # works even without activating the venv

Quick start

import asyncio
from hermes_acp_sdk import HermesClient, AgentText, AgentThought, Finished

async def main() -> None:
    async with HermesClient() as hermes:                     # spawns `hermes acp`
        print(f"connected to: {hermes.agent_name} {hermes.agent_version}")

        async with hermes.session() as s:                    # + selects a model for you
            async for ev in s.prompt("In one sentence: what is a Python traceback?"):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)       # streams in, token by token
                elif isinstance(ev, Finished):
                    print(f"\n[{ev.stop_reason}]")

asyncio.run(main())

Real output:

connected to: hermes-agent 0.18.2
A Python traceback is the error report that shows the chain of function calls leading up
to an exception, listing each file, line number, and code snippet in the call stack.
[end_turn]

Every event is typed, so you choose what to render — including the agent's reasoning:

events = [ev async for ev in s.prompt("Think it through: what is 12 * 12?")]
# {'AgentThought': 36, 'AgentText': 2, 'Usage': 2, 'Finished': 1}

How it works

┌──────────────────┐   ┌────────────────────┐   ┌───────────────────┐   ┌──────────────┐
│ HermesClient     │ → │ ACP over stdio     │ → │ hermes acp        │ → │ your LLM     │
│  spawn + handshake│   │  (JSON-RPC, TWO-WAY│   │  (subprocess)     │   │  provider    │
│                  │   │   — the agent calls│   │                   │   │              │
│ session()        │   │   BACK into you)   │   │ skills · memory   │   │ DeepSeek, …  │
│  + set_model ⚑   │   │                    │   │ tools · delegation│   │              │
└──────────────────┘   └────────────────────┘   └───────────────────┘   └──────────────┘
        │                        ▲
        │  prompt()              │  the agent's callbacks (permissions, files, terminals)
        ▼                        │  are answered by your policies — deny-by-default
   async for ev in …  ◀──────────┘
   AgentText · AgentThought · ToolCall · PlanUpdated · Usage · Finished

Without the SDK you would: spawn the subprocess and wire its pipes; implement all 12 callbacks the agent invokes on you (miss one and it breaks); demultiplex a firehose of async notifications into something usable; and then discover — the hard way — the two traps in Why this exists.

Events

s.prompt(...) yields frozen dataclasses from hermes_acp_sdk.events:

Event Fields Meaning
AgentText text A chunk of the agent's visible reply
AgentThought text A chunk of its internal reasoning
ToolCall tool_call_id, title, status, kind A tool call started, or changed status
PlanUpdated steps: list[PlanStep] The agent published or revised its plan
Usage input_tokens, output_tokens Token accounting, when the agent reports it
PermissionDenied tool_title Your policy refused a tool call
Finished stop_reason Always last; the turn is over

Unknown ACP update shapes are dropped rather than raised — a new agent version can't crash your app.

Give the agent your app's own tools

An agent only knows what its tools let it know — and it knows nothing about your database, your users, your state. ToolServer fixes that: hand it plain Python functions and it runs an MCP server inside your own process, so the tools are ordinary closures with full access to your application.

def student_weakness(student_id: str) -> str:
    """Look up which Python error family a given student most often gets wrong."""
    return db.worst_family(student_id)          # ← your live application state

async with HermesClient() as hermes:
    async with hermes.session(tools=[student_weakness]) as s:
        async for ev in s.prompt("What should student bex practise?"):
            ...

The function's name, type hints and docstring become the tool's name, schema and description — the docstring is what the model reads when deciding to call it, so write it for the model.

In-process is the point. A separate MCP server process cannot see your state; this one is your process. That is what makes "the agent looks up this student's history" possible.

Security: the server binds 127.0.0.1 on an ephemeral port and requires a per-session bearer token, so no other local process can invoke your functions. It lives exactly as long as the session.

Naming: Hermes namespaces MCP tools — student_weakness on a server named app-tools reaches the model as mcp__app_tools__student_weakness. Use that name if you refer to it explicitly in a prompt.

Safety

⚠️ What these policies cover — and what they do NOT

They govern what the agent asks you, the client, to do over ACP: permission requests, read_text_file / write_text_file, terminals.

They do not sandbox Hermes itself. Hermes ships its own internal read_file, search_files, terminal and execute_code tools that run inside its process and never pass through this SDK. While testing, we watched it answer a question by grepping the working directory instead of calling the tool it was handed.

The real boundary is the cwd you give it. Pass a directory you are willing to expose — not your home, not a repo full of secrets.

What the SDK itself grants is deny-by-default:

  • Permissions — refused. DenyAll() unless you say otherwise; a PermissionDenied event is emitted so you can see it happened. Opt in with AllowTools([...]) (allow-list), CallbackPolicy(fn) (decide per request), or AllowAll() (unsafe — local experiments only).

  • Filesystem — off. FsPolicy() allows no reads or writes. Opt in with FsPolicy(root=Path("./workspace"), allow_read=True); paths are resolved against root and any escape (including via symlinks) is rejected.

  • Terminals — off, and enabling is not a blank cheque. TerminalPolicy(enabled=True) alone still runs nothing — you must name the commands:

    TerminalPolicy(
        enabled=True,
        allowed_commands=frozenset({"/bin/ls"}),   # required — no allow-list, no execution
        cwd_root=Path("./workspace"),              # confine where it may run
        inherit_env=False,                         # default: your API keys are NOT passed on
    )
    
from pathlib import Path
from hermes_acp_sdk import HermesClient, AllowTools, FsPolicy, TerminalPolicy

async with HermesClient(
    policy=AllowTools(["Read File"]),
    fs=FsPolicy(root=Path("./workspace"), allow_read=True),
    terminal=TerminalPolicy(),                   # off
    cwd="./workspace",                           # the real boundary
) as hermes:
    ...

Profiles & model configuration

Each Hermes profile is a separate home directory with its own config.yaml, .env, SOUL.md, memories/, skills/, and sessions/. Using profiles lets an app have isolated self-learning (its own memory and skills) while sharing the same Hermes installation.

Using the default profile (no arguments)

async with HermesClient() as hermes:
    async with hermes.session() as s:
        async for ev in s.prompt("Say hello."):
            if isinstance(ev, AgentText):
                print(ev.text, end="")

No profile=, no model_config= — the SDK uses the user's default ~/.hermes.

Using an existing profile

async with HermesClient(profile="myapp") as hermes:
    ...

The SDK sets HERMES_HOME to ~/.hermes/profiles/myapp/ so the subprocess uses that profile's config, memory, skills, and sessions.

Creating a new profile with model config

from hermes_acp_sdk import HermesClient, ModelConfig

async with HermesClient(
    profile="myapp",
    model_config=ModelConfig(
        model="deepseek:deepseek-v4-pro",
        provider="deepseek",
        api_key="sk-...",          # written to .env, never config.yaml
        max_turns=50,
        system_prompt="You are a Socratic tutor.",  # written to SOUL.md
    ),
) as hermes:
    ...

Reusing the default profile's provider (clone_provider)

A separate profile (isolated skills/memory/sessions) without managing API keys:

async with HermesClient(
    profile="myapp",
    clone_provider=True,
    model_config=ModelConfig(
        model="deepseek:deepseek-v4-pro",  # override model
        max_turns=30,                       # override limit
        system_prompt="You are a Socratic CS tutor.",  # override SOUL.md
        # provider, base_url, api_key — all inherited, no credentials needed
    ),
) as hermes:
    ...

The new profile gets:

  • Inherited: provider, base_url, API keys (.env), skills
  • Fresh: memory, sessions, state (grows independently)
  • Overridden: whatever fields you set in ModelConfig

Profile name conflict avoidance

The SDK marks profiles it creates with a .hermes-acp-sdk marker file. If a profile exists but has no marker, the SDK refuses to overwrite its config unless you pass overwrite_config=True. Use auto_prefix=True to namespace profile names with app- (e.g. "mybot""app-mybot").

Hello + Joke demo

The simplest app — two prompts, same session, default profile:

import asyncio
from hermes_acp_sdk import HermesClient, AgentText

async def main():
    async with HermesClient() as hermes:
        async with hermes.session() as s:
            # Prompt 1: Hello
            async for ev in s.prompt("Say hello in one sentence."):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)
            print()

            # Prompt 2: Joke (same session — Hermes remembers the hello)
            async for ev in s.prompt("Now tell me a short programming joke."):
                if isinstance(ev, AgentText):
                    print(ev.text, end="", flush=True)
            print()

asyncio.run(main())

Why this exists

Five traps, found by driving a real Hermes. The SDK absorbs every one:

  1. new_session lies about the model. It reports a current_model_id, but inference still goes out with an empty model — so every prompt fails with HTTP 400: ... but you passed .. You must call set_session_model explicitly. session() always does this for you. This is the single biggest reason the package exists.
  2. Never pass --provider / -m to hermes. Those flags blank out the model. The provider is auto-detected from the environment (e.g. DEEPSEEK_API_KEY).
  3. Model ids are provider:model"deepseek:deepseek-v4-pro", not "deepseek-v4-pro".
  4. Hermes can't speak ACP without its extrapip install "hermes-agent[acp]", or the hermes acp subcommand does not exist.
  5. The ACP Client role is a big callback surface — session updates, permissions, file I/O, terminals, extension methods — and the agent breaks if any of it is missing. The SDK implements all of it, routed through your policies.

Developer guide

hermes_acp_sdk/
├── client.py     # HermesClient: spawn `hermes acp`, handshake, sessions   [the entry point]
├── session.py    # HermesSession: prompt() as an async generator of events
├── handler.py    # the ACP `Client` role: agent callbacks → typed events + policy decisions
├── events.py     # AgentText, AgentThought, ToolCall, PlanUpdated, Usage, Finished
├── policy.py     # PermissionPolicy, FsPolicy, TerminalPolicy  [the security boundary]
├── tools.py      # ToolServer: your Python functions → in-process MCP server
└── errors.py
  • Run tests: pip install -e ".[dev]" && pytest -q — 64 tests, all against an in-process fake ACP agent, so they are fast, offline and cost nothing.
  • Integration tests (real Hermes, marked integration): HERMES_BIN=… DEEPSEEK_API_KEY=… pytest -m integration.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hermes_acp_sdk-0.2.0.tar.gz (48.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hermes_acp_sdk-0.2.0-py3-none-any.whl (32.8 kB view details)

Uploaded Python 3

File details

Details for the file hermes_acp_sdk-0.2.0.tar.gz.

File metadata

  • Download URL: hermes_acp_sdk-0.2.0.tar.gz
  • Upload date:
  • Size: 48.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.14

File hashes

Hashes for hermes_acp_sdk-0.2.0.tar.gz
Algorithm Hash digest
SHA256 bf19cf4f56c7591e2b29309d04e387837092e8f31ba0095cac8d011720d89438
MD5 3b3d8d848a82cd95799b1e0ff6defde4
BLAKE2b-256 d23d60ab68e52509a4a7164a03746923ea79017af388aac4aa1085eeca8fc926

See more details on using hashes here.

File details

Details for the file hermes_acp_sdk-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: hermes_acp_sdk-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 32.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.14

File hashes

Hashes for hermes_acp_sdk-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9426adf7808484349299b35577eefb3cc4576c4bae228db577421af07af69d7a
MD5 65f33b55e85df69f8c2c5dc1839ab497
BLAKE2b-256 e218c770a47bb0b8f63ad01c313e607d85fc7c73fe7f4442c70a82c0bbe0dc8c

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page