The Representative Agent
July 19, 2026 · View on GitHub
A representative agent is a persistent, addressable A2A endpoint that fronts
one persona to the outside world. It answers questions about that persona,
reveals availability, and takes in requests — always under hard human gates, never
acting on its own. This guide explains the pattern so any bridge can stand one up.
The engine that runs it is generic (agents/_runtime/, CORE); everything specific
to a persona lives in a per-agent instance folder (agents/<name>/, USER).
The runtime code is the source of truth. Where this guide describes behaviour, it
matches agents/_runtime/{server,executor,runner,card,config}.py and the instance
contract in agents/_template/agent.yaml.
1. What it is
Bridge-Agent, not sub-agent
The word "agent" means two different things in this repo. A sub-agent
(.claude/agents/) is ephemeral, inward-facing machinery: it runs one task inside
your session and returns a summary — a function with tools. A Bridge-Agent
(agents/) is a persistent, addressable entity that faces outward: its body is a
runnable instance under agents/<name>/, its self is the persona in the system
prompt, and its interface is a published A2A AgentCard. A representative agent is
the outward-facing kind. (Full disambiguation table: agents/README.md.)
Two faces, one self
A Bridge-Agent has two faces over the same machinery:
- Outer face — the world: anonymous visitors, foreign agents, a website widget. This is the representative face. It discloses only public, grounded facts about the persona and captures requests for a human to act on. This guide is about the outer face.
- Inner face — the peer mesh: your own fleet and trusted peer bridges talking agent-to-agent. Same runtime, same self, opposite direction and a very different trust boundary.
The safety model below is written for the outer face, where every request is assumed hostile. An inner-face deployment relaxes the caps but keeps the same shape.
2. Instance anatomy
Copy the template to start an instance:
cp -r agents/_template agents/<name>
Each instance is fully declarative — three parts under agents/<name>/:
agent.yaml— the card spec plus every runtime knob. It carries the AgentCard fields (name,description,version,provider,skills), the network binding (host,port,public_url), the model and per-turntimeout, thegrounding_dir, the scopedallowed_tools, and the public-endpoint caps (max_concurrency,max_input_chars,max_turns,max_contexts). Any field can be overridden by an environment variable at launch (PUBLIC_AGENT_URL,AGENT_HOST,AGENT_PORT,CLAUDE_MODEL,GROUNDING_DIR,CLAUDE_BINARY,ENVIRONMENT,CORS_ALLOWED_ORIGINS) so the same files run in dev and on the host.system-prompt.md— the persona. It is appended to the model's base prompt (--append-system-prompt), so the model keeps its capabilities and gains the persona's voice, facts, and disclosure boundary. This file defines who the agent is and what it may reveal. Optionally, declared grounding files can be embedded straight into this prompt at load (inline_grounding:) so the agent answers from context instead of paying a file-read round-trip per question.tools/— instance-specific scoped tools, written as small argparse CLIs. The runtime never invokes them by name from the working directory; it invokes them by absolute path. Inagent.yamlyou reference them with a${tools_dir}placeholder that the loader substitutes for the resolved absolute path ofagents/<name>/tools(and${instance_dir}for the instance root). The same substitution is applied insidesystem-prompt.md, so the prompt tells the agent to call the exact absolute path thatallowed_toolspermits — necessary because the agent's working directory is the grounding dir, not the instance folder. The CORE template ships one reference tool,_template/tools/intake_notify.py, implementing the fixed-recipient intake + owner-notify pattern (§4). Copy it into your instance and override itssend()with your transport; CORE ships the pattern, never a provider.
Run it and probe the card:
cd agents && uv run python -m _runtime.server --agent <name> --port 8011
curl -s localhost:8011/.well-known/agent-card.json | head
curl -s localhost:8011/health
3. The runtime
One subprocess per turn
The brain is claude -p. For every turn the runner spawns a fresh subprocess
(SubprocessClaudeRunner), feeds it the assembled prompt, and reads back exactly one
answer. claude -p is stateless, and the A2A SDK creates a new task per message, so
conversation memory is kept by the executor keyed on the client's stable
context_id and folded into each prompt as a short transcript. The persona is
injected with --append-system-prompt; the grounding dir is the subprocess cwd;
extra read-only mounts are added with --add-dir; the tool footprint is whatever
allowed_tools permits (default read-only Read,Glob,Grep).
Plain Starlette + a2a-sdk 1.x
The server is a plain Starlette app, not an A2AStarletteApplication wrapper —
a2a-sdk 1.x has no such wrapper. build_app(cfg) composes the SDK's JSON-RPC routes
and agent-card routes into a Starlette app, wired to a DefaultRequestHandler over
an InMemoryTaskStore, with the ClaudeAgentExecutor as the agent executor. A CORS
middleware restricts origins to the configured list. Two small correctness shims live
here: enable_v0_3_compat=True keeps the 0.3-dialect JSON-RPC wire working (method
names on the RPC endpoint — it does not affect the card), and a route wrapper
restores the A2A-spec error codes that a2a-sdk 1.x otherwise flattens to the generic
JSON-RPC InternalError. That includes the version mismatch a 0.3 method name paired
with a 1.x A2A-Version header produces, which is additionally given the same typed
data details the v1 path emits, so both dialects report the error identically.
Discovery and endpoints
- AgentCard is served at the modern
/.well-known/agent-card.json, with a legacy path alias at/.well-known/agent.jsonfor clients that only know the old URL. The alias serves the same v1.0 card, byte for byte — a client that needs the 0.3 discovery dialect (a top-levelurlfield) is not served by it, on either path; 0.3 compat here is on the wire, not in discovery. In a2a-sdk 1.x the singleurlfield is gone; the card advertises a list ofsupported_interfaces, each anAgentInterfacewith the public URL and a JSON-RPC transport binding (protocol_binding=TransportProtocol.JSONRPC). The card separately carriescapabilities=AgentCapabilities(streaming=True, push_notifications=False). message/sendreturns a single completed task;message/streamreturns the same turn as Server-Sent Events, so a visitor sees the answer build up.tasks/*(get, cancel, …) are handled by the SDK request handler.tasks/cancelactually aborts the in-flightclaude -prun: the executor cancels the asyncio task, whosefinallykills the subprocess, and emitsCANCELLED./healthis a plain GET returning{"status":"ok","agent":<instance>}.
The final answer is delivered both as a durable Task artifact and in the completed status message, so a widget that reads either surface renders one bubble.
4. The safety model
A public endpoint is assumed hostile on every request. Several independent layers enforce that a visitor can never make the agent read private files, exfiltrate secrets, or act on anyone's behalf.
Read-confinement: cwd = grounding_dir
The single most important control. The subprocess runs with its working directory
set to the grounding dir — the folder of public content the agent is allowed to
know. That cwd confines the read-only file tools (Read, Glob, Grep) to public
content: they cannot reach the bridge's private tree (identity/, work/, memory,
customer data). Scoped instance tools still resolve because they are invoked by
absolute path, not relative to cwd.
Scoped allowed_tools
allowed_tools grants read-only file access plus only the intended instance
tools, each by absolute path — no generic shell, no write tool, no arbitrary send.
The default is Read,Glob,Grep; an intake CLI is added explicitly, e.g.
Read,Glob,Grep,Bash(python3 ${tools_dir}/book_request.py:*).
The read-only-shell denylist (backstop)
There is a structural gap in the permission layer. The runner launches with
--permission-mode acceptEdits so a headless -p agent never hangs on an interactive
prompt — but acceptEdits auto-allows any shell command the engine classifies as
read-only, independent of allowed_tools. A prompt-injected visitor could otherwise
have the agent run a read-classified binary (cat, security, sqlite3, git, …)
by absolute path and fold the output — a keychain secret, the intake log, a file
outside the grounding dir — into its answer. Because cwd-confinement binds only the
Read/Glob/Grep file tools and not Bash, the runtime ships a broad denylist
(--disallowedTools) of the known file-reader, search, stream-editor, hashing,
archiver, VCS, secret-store, and network-egress binaries; deny rules override the
read-only auto-allow. python3 is intentionally left allowed because the scoped
instance tools are python3 <abs>/tool.py (and python3 -c … is not classified
read-only, so it is not auto-run). This denylist is a backstop, never the control —
it raises the bar but can never be complete. The real read-confinement is the cwd
above plus, ideally, an OS sandbox (below).
Project-only settings
The runner passes --setting-sources project, so it loads only the project's
settings — never the host user's ~/.claude allowlist or hooks. A host preference can
therefore never silently widen this internet-facing agent.
Fixed-recipient intake — a contract for tool authors
A representative agent's capability has two axes, and both are pinned per instance:
what it may KNOW — its grounding dir, the only content it can read (cwd
read-confinement above) and be told about (files inlined into system-prompt.md) — and
what it may DO — the scoped allowed_tools, an outward action always behind a human
gate. The read axis is confined by cwd; the write axis is confined by this contract.
An intake/notify tool lets the agent capture a request and alert its owner without
letting a hostile visitor redirect that alert. The CORE reference is
agents/_template/tools/intake_notify.py; a hermetic CORE test
(agents/tests/test_intake_notify.py) locks each clause below. Every intake/notify
tool an instance ships MUST satisfy them:
- No recipient argument. The recipient is resolved only from operator config
(
AGENT_NOTIFY_TOenv). There is no--to/--recipientflag and no code path from any CLI arg, agent instruction, or visitor-supplied field to the recipient — so a prompt-injected visitor cannot redirect output. (Enforced by absence, not validation.) - Capture is durable and first. The request is appended to an owner-only (
0600) local log before any notify attempt, and the tool exits0even if the capture write fails (printing a fixed string, never the exception, so a record never reaches stderr). - Notify is best-effort and never raises.
notify()returns a bool and never propagates an exception; a notify failure can never crash the agent turn. - Unconfigured is loud, not silent. With no recipient/transport configured the tool stays capture-only and writes a WARN audit line naming the missing knob.
- The audit log is PII-free. One line per attempt — outcome + a curated cause only; the visitor's summary/detail/fields and the recipient address never appear in it.
- No autonomous outward action. The tool captures and notifies the owner; it never books, replies, or acts on a third party's behalf. Every outward action stays gated.
- CORE ships the pattern, not the transport. The
send()seam raises by default; the provider (MS Graph / SMTP / Signal / …), its secret (read from a vault at runtime, never env/argv), and the owner address live only in the USER instance. An instance that renders visitor text into markup must escape it before embedding.
Optional session attribution. The runner also exports AGENT_CONTEXT_ID into the
tool's inherited subprocess env whenever the turn arrives through the A2A executor
(§3), which substitutes default when the request carries no context id — a tool MAY
read it to attribute a captured request to the session that made it instead of logging
"unknown". It falls back to unset, never an empty placeholder, for a direct invocation
outside the A2A executor, so a tool must treat its absence as normal, not an error.
Public-endpoint caps
An ungated endpoint will be hit. The executor enforces max_concurrency (shed load
with a friendly "busy" message rather than spawning unbounded subprocesses),
max_input_chars (reject overlong input), max_turns (transcript depth per context),
and max_contexts (an LRU bounded in both dimensions — turns per context and number
of contexts — so a stream of unique session IDs cannot grow memory without bound). Add
a per-IP edge rate-limit at your CDN.
Honest card
Advertise only skills the prompt and tools actually implement. The AgentCard is the agent's self-description; it must not claim a capability the instance cannot deliver.
OS sandbox — the named real fix
The layers above are defense-in-depth on top of the model's own refusal. The real
fix for read-confinement is an OS sandbox that confines reads to the grounding dir and
blocks network and secret-store access with the mechanism the host supports — a
container running as an unprivileged user on Linux, or a sandbox-exec profile /
dedicated low-privilege user on the host OS. Name it as the goal; the denylist buys
time until it is in place.
5. The answer contract
The runtime upholds one hard contract on every turn: exactly one non-empty answer,
delivered within timeout — never a hang, never a blank bubble. A public visitor
must never watch "…working" forever or receive an empty reply. Three mechanisms hold
that line, all in the runner:
- Raised line buffer.
claude -p --output-format stream-jsonemits each event as one NDJSON line, and a singleReadtool result can embed a whole grounding file on one line — easily past asyncio's default 64 KiBStreamReaderlimit, which would raiseLimitOverrunErrorand turn the turn into a textless FAILED. The runner raises the subprocess pipe limit (limit=_STREAM_LIMIT, 64 MiB) so big events parse. - Overall deadline. A
StreamReaderhas no total timeout, so the runner wraps line reads in an overall deadline. If the subprocess streams continuously pasttimeout, the turn still ends — with the configured "try again" text, never a hang. - Accumulated-text fallback. If no terminal
resultevent ever arrives, the runner synthesises the answer from the assistant text it accumulated during the stream; if even that is empty, it returns a plain "no answer" message. A dropped or late partial delta self-corrects because the terminal result is authoritative.
Two production incidents bought this contract; it is locked by a hermetic regression
net (agents/tests/test_runner.py, stubbed — no real claude, no network) run in CI
on every PR to main (and every main push) that touches agents/**. Don't weaken
these paths without keeping the tests green.
Latency is a knob, not a bug. A broad question that makes the model read the whole
grounding and reason a full answer can take roughly a minute or two on a mid-tier
model. That is fine over a tunnel: SSE keepalive pings hold the connection open across
the silent reasoning gap, and on expiry timeout returns text rather than a blank —
so give timeout generous headroom. To make a turn fast, pull the levers that
remove work: keep the grounding small, or inject the public content straight into
system-prompt.md (inline_grounding:) so the agent answers from context and skips
the per-turn Read and its big-line cost, or run a faster model (trading some
depth for latency on pure Q&A).
6. Deploy
Host the runtime on a remote as a managed service (launchd on the host OS, systemd on
Linux), behind a tunnel that maps the public_url hostname to the local port the
process binds. Bind the process to loopback and let the tunnel be the only public
ingress. Set the persona-facing env overrides (PUBLIC_AGENT_URL, GROUNDING_DIR,
model, CORS) at the service level so the tracked instance files stay host-agnostic.
Track the deployment two ways: as an infra/channels/<name>.yaml entry (what the
endpoint is, where it lives, how to reach it) and in the hosting remote's service
list. Per the deploy-reconciliation rule, the declared status: is never trusted —
verify the live service manager and probe /health and the AgentCard endpoint. A
config that says "running" proves nothing; the service manager and a live request do.
7. Reaching MCP-only clients
Everything above makes the agent addressable over A2A — but a growing share of
the frontends people actually talk to (Claude connectors, ChatGPT developer mode,
Gemini's tool clients) speak MCP, not A2A, and an A2A-only deployment is simply
invisible to them: nothing to add as a connector, nothing to list as a tool. The CORE
gateway under agents/_gateway/ closes that gap — a
thin, stateless MCP→A2A translation layer that exposes list_bridges /
get_bridge_card / ask_bridge over MCP and forwards each ask to the target agent's
A2A endpoint unchanged. It adds reach, not privilege: the gateway is just another
client in front of your agent and bypasses none of the guardrails in §4. Stand up the
representative agent first for A2A reachability; add the gateway on top when you also
want it discoverable from MCP-only surfaces.