Skip to main content

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.

Release files for hermes-acp-sdk 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hermes-acp-sdk 0.2.0
File Size Uploaded
hermes_acp_sdk-0.2.0.tar.gz 48.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hermes-acp-sdk 0.2.0
File Interpreter ABI Platform
hermes_acp_sdk-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 81.6 kB

Release files / hermes_acp_sdk-0.2.0.tar.gz

Download URL hermes_acp_sdk-0.2.0.tar.gz
Size 48.8 kB
Tags Source
SHA-256 checksum
How to use checksums
bf19cf4f56c7591e2b29309d04e387837092e8f31ba0095cac8d011720d89438
BLAKE2b-256 checksum
How to use checksums
d23d60ab68e52509a4a7164a03746923ea79017af388aac4aa1085eeca8fc926
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.14

Release files / hermes_acp_sdk-0.2.0-py3-none-any.whl

Download URL hermes_acp_sdk-0.2.0-py3-none-any.whl
Size 32.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9426adf7808484349299b35577eefb3cc4576c4bae228db577421af07af69d7a
BLAKE2b-256 checksum
How to use checksums
e218c770a47bb0b8f63ad01c313e607d85fc7c73fe7f4442c70a82c0bbe0dc8c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page