One Agent, Four Doors
Packaging an AI agent for the terminal, REST, MCP and AG-UI from one core — with Hermes behind it, and why “AGP” is not a fifth
A two-minute screencast makes a claim worth taking seriously. Hermes is a library, not just a CLI, says the description of ActionableOps’ video on using Hermes Agent from Python: import it, call it, ship it inside your app. Three lines, and the agent that Nous Research built as a terminal tool is answering inside your FastAPI route, your Discord bot, your CI step.
That claim raises a bigger one. If the agent is a library, then the CLI is just one door onto it. A REST endpoint is another. So is an MCP server, so that other agents can call yours. So is an AG-UI backend, so that a web front end can watch it work. Could one Python package open all of them, from one core, written once?
Yes — but not as five equal doors, and not with the API the video shows. This article builds the package, tests every door without spending a model token, and sorts the five names you will hear into three tiers of effort. One of the five turns out not to be a door at all, and one of them has not existed under that name for fifteen months.
- One core, thin adapters, works. CLI, REST and MCP are each under sixty lines on top of a single
Agentport. The worked example has twelve source files and fourteen tests, and passes with no key and no network. - AG-UI is a streaming protocol, so the core must stream. A door built on
chat()— message in, string out — throws away exactly what AG-UI exists to show. Hermes can do it, because its real constructor takes astream_delta_callbackthe documentation never mentions. - “AGP” is now SLIM, renamed in June 2025, and it is a transport under MCP and A2A, not a fifth surface beside them. Run the other doors over it if you need its guarantees; do not build “an AGP interface.”
- Hermes is an MCP client, not a server. Making your agent callable over MCP is a layer you write. It is the smallest door here.
- Fresh agent per request. Hermes’s own docs are blunt: an
AIAgentholds state and must never be shared across threads. Every door below constructs one per call.
What the video shows
Hermes Agent is Nous Research’s open-source agent — a terminal interface with memory, skills, a scheduler, and a gateway that puts the same agent on Telegram, Discord, Slack, WhatsApp and Signal. It is MIT-licensed, a year old, and has a quarter of a million GitHub stars. It is not on PyPI: you clone it, run uv sync, and run your application from inside the checkout. That matters later.
The video’s whole script fits in its description, and it matches the official guide it links to. The pattern is this:
from run_agent import AIAgent
agent = AIAgent(model="anthropic/claude-sonnet-4.6", quiet_mode=True)
print(agent.chat("What is the capital of France?"))chat() runs the entire tool loop — searches, terminal commands, retries — and returns the final text. For more control, run_conversation() returns a dict with final_response and the full messages list, and you pass that list back as conversation_history to get a second turn that remembers the first. Three flags make it safe to embed: quiet_mode=True (or the CLI’s spinners land in your output), skip_memory=True (no reads or writes to the agent’s persistent memory), and skip_context_files=True (no AGENTS.md from the working directory in your system prompt). enabled_toolsets and disabled_toolsets fence what it may do.
And one rule, stated in the video and in bold in the guide: an AIAgent holds state. Create a fresh one per thread or task. Never share it.
The guide’s own examples are the seed of this article. The same AIAgent, constructed with the same three flags, appears in a FastAPI route, a Discord handler, and a CI script that reviews a diff. Nobody calls it a pattern. It is one: a core that does the work, and adapters that give it a shape. Software architects have called it ports and adapters since 2005. It is worth naming, because naming it tells you what the adapters may and may not know.
What the video does not show
The guide documents chat() and run_conversation(), both of which block. You call, the loop runs, you get the answer. That is the right shape for a terminal, a REST endpoint, and a CI step. It is the wrong shape for a front end that wants to show the reply as it is written and the tool calls as they happen — and that is the entire point of AG-UI.
So before building anything, I read the constructor. AIAgent.__init__ in run_agent.py takes around ninety parameters. Among them, none of which the guide mentions:
tool_start_callback, tool_progress_callback, tool_complete_callback,
thinking_callback, reasoning_callback, stream_delta_callback,
interim_assistant_callback, status_callback,
event_callback: Callable[[str, dict], None], ...
One line in agent/stream_delivery.py settles what the important one does: stream_delta_callback is called with a single positional argument, the next chunk of the assistant’s text. That is a token stream. Hermes streams; the library guide just does not say so.
This is the hinge of the article. A “write once” package can only offer streaming doors if the core streams. Hermes’s core does. The convenience API hides it.
Sorting the five doors
Here is what the question actually contains, once each name is checked against the thing it names.
| Door | What it is | What the adapter costs |
|---|---|---|
| CLI | A process, stdin and stdout | Trivial. Print the deltas. |
| REST | Request in, JSON out; optionally SSE out | Small. The guide’s own FastAPI example, plus a streaming route. |
| MCP | Your agent as a tool other agents call | Small — and request/response by nature. |
| AG-UI | Your agent’s run as a typed event stream a front end renders | Real work: translate the core’s events into the protocol’s, in order. |
| “AGP” | Renamed SLIM in June 2025; a transport under MCP and A2A | Not a door. A place to run the other doors’ traffic. |
Three thin adapters, one real one, and one that is a different kind of thing. The rest of the article builds the four and explains the fifth.
The core: one port, six events
The package is called onedoor. Its core is two files, and the first is the only vocabulary a door is allowed to speak:
@dataclass(frozen=True)
class RunStarted: run_id: str
@dataclass(frozen=True)
class TextDelta: text: str # in order; concatenate to get the reply
@dataclass(frozen=True)
class ToolStarted: call_id: str; name: str; arguments: dict
@dataclass(frozen=True)
class ToolFinished: call_id: str; name: str; result: Any
@dataclass(frozen=True)
class RunFinished: run_id: str; text: str
@dataclass(frozen=True)
class RunFailed: run_id: str; error: strSix shapes, deliberately fewer than any wire protocol. AG-UI has thirty-six event types; MCP has a call and a result; a terminal has bytes. The core emits the intersection of what every door can carry, and each door translates outwards. The moment the core starts emitting AG-UI’s STATE_DELTA because one door wants it, the other three doors have to learn to ignore it, and the word core has stopped meaning anything.
The second file is the port:
class Agent(Protocol):
def run(self, message: str, *, history: list[Message] | None = None) -> Iterator[Event]:
"""The first event is RunStarted; the last is RunFinished or RunFailed.
TextDeltas arrive in order and concatenate to RunFinished.text.
Every ToolStarted is followed by a ToolFinished with the same call_id.
The implementation keeps no state between calls."""One method. It returns an iterator, not a string, so that a door which can stream, streams, and a door which cannot drains it:
def final_text(events: Iterator[Event]) -> str:
text = ""
for event in events:
if isinstance(event, RunFinished):
text = event.text
return textThe implementation keeps no state between calls is the rule from the video, promoted to a contract. Memory is the caller’s problem: pass history in. That is what lets a REST door take a fresh agent per request without thinking about it.
The Hermes adapter: a thread and a queue
run_conversation() blocks, and streams by calling back on the same thread. The only way to turn that into an iterator without modifying Hermes is to run it on a worker and let the callbacks push into a queue that the iterator drains:
def run(self, message, *, history=None):
run_id = uuid.uuid4().hex
events: queue.Queue = queue.Queue()
def worker():
try:
agent = AIAgent( # one per run, as the docs insist
model=self.model, quiet_mode=True,
skip_memory=True, skip_context_files=True,
max_iterations=self.max_iterations,
stream_delta_callback=lambda text: events.put(TextDelta(text)),
tool_start_callback=on_tool_start,
tool_complete_callback=on_tool_complete,
)
result = agent.run_conversation(message, conversation_history=list(history or []))
events.put(RunFinished(run_id, str(result.get("final_response", ""))))
except Exception as exc:
events.put(RunFailed(run_id, f"{type(exc).__name__}: {exc}"))
finally:
events.put(_DONE)
yield RunStarted(run_id)
threading.Thread(target=worker, daemon=True).start()
while (item := events.get()) is not _DONE:
yield itemTwo honesty notes, both also in the code. First, the shapes of the two tool callbacks’ arguments are not documented anywhere I could find, so the adapter passes them through unparsed as {"args": [...], "kwargs": {...}} until you have seen a real one — guessing a payload shape is how adapters break on the first live run. Second, the import of run_agent is lazy and the adapter takes an optional factory, so the bridge can be tested with a stand-in AIAgent that behaves the way the source says the real one does: callbacks on the calling thread, a blocking run_conversation that returns a dict. The test suite does exactly that, and includes a provider failure becoming a RunFailed event rather than an exception escaping through a door.
The scripted agent: the claim, made testable
The tests never touch a model. They run every door against ScriptedAgent, a deterministic implementation of the same port that emits every event it can: one tool call, then a reply streamed word by word.
yield RunStarted(run_id)
yield ToolStarted(call_id, "word_count", {"text": message})
yield ToolFinished(call_id, "word_count", len(message.split()))
for word in reply.split(" "):
yield TextDelta(word + " ")
yield RunFinished(run_id, reply)This is not a mock in the derogatory sense. It is the second implementation of the port, and having two is what makes write once a testable statement instead of an aspiration: a door that passes against the scripted agent needs no change to serve Hermes, because it never saw either. ONEDOOR_AGENT=hermes in the environment swaps them.
Door one: the terminal
for event in agent.run(args.message):
if isinstance(event, TextDelta):
sys.stdout.write(event.text); sys.stdout.flush()
elif isinstance(event, RunFinished):
sys.stdout.write("\n")That is the door. A --json flag prints every event as one JSON object per line instead, which is the most useful debugging tool in the package: it is the raw stream the other three doors translate, made visible.
$ pixi run onedoor "how many words is this"
Your message has 5 words. I have seen 0 earlier turns.
Door two: REST, twice
FastAPI, two routes. The first is the request/response shape from Hermes’s own guide:
@app.post("/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
agent = get_agent() # fresh per request
return ChatResponse(response=final_text(agent.run(request.message, history=request.history)))The second is the same call as Server-Sent Events, one internal event per data: line:
@app.post("/chat/stream")
def chat_stream(request: ChatRequest) -> StreamingResponse:
agent = get_agent()
def lines():
for event in agent.run(request.message, history=request.history):
yield f"data: {json.dumps({'event': type(event).__name__, **asdict(event)})}\n\n"
return StreamingResponse(lines(), media_type="text/event-stream")Under uvicorn, curl gets the JSON from the first and fifteen SSE events from the second, RunStarted to RunFinished. Note what the streaming route is not: it is not AG-UI. It streams the core’s vocabulary. A front end that consumes it is coupled to this package. That is fine for your own front end and the reason a standard exists for everyone else’s.
Door three: MCP — the agent as a tool
This door needs a correction first. Hermes’s MCP guide is entirely about Hermes consuming MCP servers — mcp_servers: in its config, hermes mcp add, whitelisting tools. “Hermes remains the agent; MCP servers contribute tools.” Nothing in it makes Hermes available as a server. If you want other agents — Claude Code, Claude Desktop, another Hermes — to call yours, that is the layer you write. It is the smallest one here:
from mcp.server.mcpserver import MCPServer
server = MCPServer("onedoor", instructions="One tool, `ask`. Send a message; get the reply.")
@server.tool(description="Ask the agent a question and get its final reply as text.")
def ask(message: str) -> str:
return final_text(get_agent().run(message))
if __name__ == "__main__":
server.run("stdio")Two things to notice. The import: mcp 2.x renamed FastMCP to MCPServer, and the 1.x import — which is what most tutorials still show — now raises a ModuleNotFoundError whose message points at the migration guide. And the return type: MCP tools are request/response. This door drains the stream and returns the final text, and the tool calls and token stream that the other doors surface are invisible here. That is the protocol’s shape, not a shortcoming of the adapter, and pretending otherwise would misrepresent it.
The tests call server.list_tools() and server.call_tool() in-process. The real check was the SDK’s own client driving the door over stdio as a subprocess: server onedoor, protocol version 2025-11-25, ask returning the expected text. server.streamable_http_app() gives a Starlette app if you would rather mount it beside the REST door.
Door four: AG-UI — the agent inside a front end
AG-UI is CopilotKit’s Agent-User Interaction Protocol, and its own one-line placement is the clearest statement of the stack: MCP gives agents tools; A2A lets agents talk to agents; AG-UI brings agents into user-facing applications. A front end posts a RunAgentInput — thread id, run id, the message history, the tools it can offer, its state — and the backend answers with a stream of typed events: RUN_STARTED, then tool-call and text-message lifecycles, then RUN_FINISHED. Thirty-six event types in the current Python package, including state snapshots and deltas, reasoning, steps and subagents. (The README still says “about sixteen.” Read the enum.)
That is why this door needed the core to stream. It is a pure function from the core’s events to AG-UI’s, in order, as they happen:
def translate(events, *, thread_id, run_id):
message_id = uuid.uuid4().hex
text_open = False
for event in events:
if isinstance(event, ToolStarted):
yield ToolCallStartEvent(tool_call_id=event.call_id, tool_call_name=event.name)
yield ToolCallArgsEvent(tool_call_id=event.call_id, delta=json.dumps(event.arguments))
yield ToolCallEndEvent(tool_call_id=event.call_id)
elif isinstance(event, ToolFinished):
yield ToolCallResultEvent(message_id=uuid.uuid4().hex, tool_call_id=event.call_id,
content=json.dumps(event.result))
elif isinstance(event, TextDelta):
if not text_open:
yield TextMessageStartEvent(message_id=message_id, role="assistant")
text_open = True
yield TextMessageContentEvent(message_id=message_id, delta=event.text)
elif isinstance(event, RunFinished):
if text_open:
yield TextMessageEndEvent(message_id=message_id)
yield RunFinishedEvent(thread_id=thread_id, run_id=run_id)
elif isinstance(event, RunFailed):
yield RunErrorEvent(message=event.error)The route wraps it: take the last user message as the prompt, the earlier messages as history, and write each translated event through the package’s EventEncoder, which produces data: {...} lines with the camel-cased keys the JavaScript side expects. Under uvicorn, one curl produced nineteen events — RUN_STARTED, the four tool-call events, TEXT_MESSAGE_START, eleven TEXT_MESSAGE_CONTENT deltas, TEXT_MESSAGE_END, RUN_FINISHED — and the deltas reassembled into the reply.
Now look back at the first code block in this article. agent.chat() returns a string. You could wrap that in an AG-UI endpoint: RUN_STARTED, one TEXT_MESSAGE_CONTENT with the whole reply, RUN_FINISHED. It would validate. It would also be a lie about what the agent did for the last forty seconds, and a front end built on it could show nothing but a spinner. The streaming door is only honest because the core streams, and the core only streams because Hermes’s constructor has a hook its guide never mentions.
AG-UI’s STATE_SNAPSHOT and STATE_DELTA — shared state between the agent and the front end, edited by both — have no counterpart in the core’s six events, and neither do its human-in-the-loop interrupts. The example does not pretend to cover them. Adding shared state to the port, without letting one door’s needs leak into the others, is the next article.
The fifth name: from AGP to SLIM
“AGP” was the Agent Gateway Protocol, from the AGNTCY collective — Cisco-led, under the Linux Foundation. Search its repositories today and there is no agp. There is slim, whose commit history carries crates called agp-config, agp-tracing and agp-mcp, and a pull request, #293 “refactor: rename AGP to SLIM”, merged on 3 June 2025. The project is SLIM — Secure Low-Latency Interactive Messaging — and describes itself as “the secure, scalable transport layer for AI agent protocols like A2A and MCP.”
Read that sentence as an architect. A transport layer is not a surface you build an adapter for. It is where the other adapters’ bytes travel: a data plane routing on hierarchical names, a session layer with end-to-end MLS encryption and group membership, a control plane. AGNTCY ships slim-mcp-python and slim-a2a-python precisely so that an MCP server or an A2A agent can run over it without changing what it says.
So the answer to “and AGP?” is two sentences. If you need its guarantees — authenticated, encrypted, low-latency messaging between agents at scale — run the MCP door over SLIM. And write SLIM, because anyone specifying “AGP” in 2026 is naming something that has not existed under that name for fifteen months, and the people reading the specification will look for it.
The verdict
Does one package with every door make sense? Yes, and the worked example is the proof: twelve source files, fourteen tests, four doors, no model tokens spent, and the real doors checked over stdio and HTTP as well. But the question’s framing — five interfaces, written once — undersells the differences in a way that would cost a team its planning:
- Three doors are cheap because they are shapes on a call: terminal, REST, MCP. The guide’s own examples are already most of the way there.
- One door is real work because it is a shape on a run: AG-UI needs the core to stream, and needs a translation that respects the protocol’s ordering. Budget it separately, and check the core can stream before you promise it.
- One is not a door. SLIM is where the doors’ traffic can go, and it has a new name.
And the rule that holds all of it together is the one the video ends on: a fresh agent per request, state passed in, nothing shared. Every door in the example constructs its own. That is not defensive programming. It is what makes “write once” true — the doors can only be thin because the core is stateless at the boundary, and the core can only be stateless because memory is the caller’s job.
What to do this week
- Find your
chat(). Whatever agent you have, locate the one call that does the work, and check whether it can stream. If it cannot, that decides which doors you can honestly build. - Write the port before the doors. Six events or fewer. If a door needs a seventh, ask whether every door can carry it.
- Write the scripted agent second. It costs an hour and turns “write once” into a test.
- Build the MCP door first. It is the smallest, it makes your agent reachable from Claude Code the same afternoon, and it forces the request/response case to be right before you attempt the streaming one.
- Spell it SLIM.
Sources
Research notes — what was read in full, what was not obtained, and the claims left unverified — are in the accompanying folder. The worked example is docs/2026-09-13-agent-interfaces/example/; pixi run test runs every door.
- The video. ActionableOps, Hermes Agent: Use Hermes as a Python Library, youtu.be/X13ZtyNKB0I. An independent how-to; Hermes is built by Nous Research.
- Hermes. NousResearch/hermes-agent (MIT); the guide Using Hermes as a Python Library; the guide Use MCP with Hermes;
run_agent.pyandagent/stream_delivery.pyonmain, read 13 September 2026. - MCP. Model Context Protocol; the Python SDK
mcp2.2.0 and its v2 migration guide. - AG-UI. ag-ui-protocol/ag-ui (MIT); docs.ag-ui.com; the Python package
ag-ui-protocol0.1.22. - SLIM, formerly AGP. agntcy/slim; the rename, PR #293, merged 2025-06-03; docs.agntcy.org/slim.
- The pattern. Alistair Cockburn, Hexagonal architecture, 2005.