Two agents talking to each other
Agent A asks agent B a question. B runs its own sealed mission — its framework, its tools, its model — and A gets B’s answer. Nothing is shared between them but a name and the rules below.
In a chat you just say so — “ask <B> what the weather in Chennai is” — and A uses its
connect-to-agent memory (below). Every install has it
(agent r30, harness engine r41).
A ──(1) who is B? ────────────► registry.fastagents.net by NAME, over DDE (a sealed query, not a GET)A ──(2) open a session ───────► B /external/auth B's policy: may A talk to me?A ──(3) the question ─────────► B /external/incoming B runs its ONE sealed mission B ──(4) a model, please ──► B's router may B relay for A? B ──(5) its tools ────────► other agents' APIs may B call them?A ◄─(6) the answer ─────────── BVerified 2026-09-27: from a workspace chat, an agent asked a PydanticAI agent by name through
connect-to-agent; the PydanticAI agent answered with live weather and BBC headlines it fetched from two
API agents. Asked to pass a question on to a LangGraph agent, it declined — no relay.
<A> and <B> below are the agents’ identity names — HEXAEIGHT_RESOURCENAME in each one’s
env-file, the name in its boot log’s identity: line — not folder names.
Prerequisites — each agent works on its own
Section titled “Prerequisites — each agent works on its own”Before two agents can coordinate, each one must be a working agent by itself. None of this is about the other agent; an agent missing any of it cannot answer anyone, its own owner included.
| each agent needs | |
|---|---|
| its own identity | the registry holds one address per name. Two agents under one identity cannot both be found by name — an agent others must reach by name needs an identity of its own |
| its own model (an LLM) | its engine asks its router for a model, so its router must admit it: <agent> -> * inbound (it may relay turns) and <agent> -> <models> outbound. This is part of standing an agent up — the same as for any agent. The installer writes these rules for the agent it installs |
| a mission it serves | the agent being asked is a runner (add-runner) sealed to a mission. An API-only agent ("apiOnly": true) answers only named API routes and refuses a question: this agent serves API requests only |
| a way to be found | registered ("register": true) with a public address — a tunnel, "reach": {"mode": "cloudflared", "bin": "<path to cloudflared>"} — or the caller is given its address explicitly |
Giving an agent its model, on its router’s folder:
cp -p router-policy.he "router-policy.he.bak_$(date +%Y%m%d_%H%M%S)"set -a; . ./env-file; set +aprintf 'N\n%s\n*\n\ny\n' "<agent>" | ./hexaeight-router-linux-x64 --init-policy # <agent> -> * both waysheia restart router # the router reads its policy only at startReplace * (the models) with a route pattern to limit which models the agent may use. A registered
agent’s boot log shows it can be found:
[reach] mode=cloudflared tunnel up https://…trycloudflare.com[register] OK -> https://registry.fastagents.net as <B> = https://…trycloudflare.com (http 200)Measured: a newly stood-up agent was found by name and accepted the session, then could not answer: its router did not admit it (
NOT authorized with router …: HTTP 400). That was a missing prerequisite — the agent had no model — not a coordination rule.
Coordination — the rules between the two
Section titled “Coordination — the rules between the two”With both agents working, what lets A talk to B is each agent’s own policy, both sides, both directions. Policy is default-deny.
On agent B (the one being asked) — run in B’s folder:
./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "<A>,*,inbound,allow" # A may open a session and ask./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "*,<A>,outbound,allow" # B may answer / reach AThe destination is
*, not B’s own name. A session request is checked as sender = A, destination = self. A rule<A>,<B>,inboundlooks right and does not match.
On agent A (the one asking) — in every folder that holds A’s identity:
./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "*,<B>,outbound,allow" # A's engines may reach B./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "<B>,*,inbound,allow" # B's answers and calls may reach Aadd-runner --peer <A> writes B’s outbound row for you when B is created.
On every API agent B’s tools call (a weather or news service, say) — the same pair, for B:
./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "<B>,*,inbound,allow"./hexaeight-agent-linux-x64 hexaeight-agent.json --add-rule "*,<B>,outbound,allow"Agent policy changes are picked up without a restart.
Routers — one case to know about
Section titled “Routers — one case to know about”Coordination needs no router change when each agent already has its model (the prerequisite above) and the answering agent’s router already knows the caller. Two things are worth knowing:
-
The answer’s model is always B’s — the route B’s engine is sealed to, on B’s router. A’s router is not involved in A asking B, and A cannot choose B’s model.
-
B’s router checks who each turn is for. A turn from A runs for A, so B’s router must allow A as the turn’s sender. When A and B share a router that already has
<A> -> *rows (as it does for A’s own work), that is already true. When B’s router has never seen A — different routers, different operators — add A there:Terminal window printf 'N\n%s\n*\n%s\ny\n' "<A>" "<B>" | ./hexaeight-router-linux-x64 --init-policy # <A> -> <B> in, <A> -> * outheia restart routerMeasured on a shared router only; on separate routers this follows from the same check and has not been run end to end yet.
| router change for A to talk to B | |
|---|---|
| both agents have their model, one shared router | none |
| B’s router has never seen A | on B’s router: allow A as a turn’s sender (above) |
| they also talk the other way | the mirror, on A’s router, for B |
| A acts for a person | the person, as sha512(email), in B’s vouch, B’s inbound rule and B’s router — the BYOA stores |
From a chat or a mission — connect-to-agent
Section titled “From a chat or a mission — connect-to-agent”Every install has a memory named connect-to-agent. It holds no documents: searching it asks
another agent.
memory_search("connect-to-agent", "<B> What is the weather in Chennai?")memory_search("connect-to-agent", "<B>") # the name alone: B describes what it can doThe first word is the agent’s name, the rest is the question. The model does this itself when you ask
it to talk to another agent; the answer comes back headed # Answer from agent '<B>', and the model
reports it as B’s answer. Framework runners have the same memory built in.
- One conversation per chat. Follow-ups in the same chat continue the conversation with B; a new chat starts a new one. The chat’s own id never leaves A’s machine.
- It waits for the answer — up to 5 minutes — and returns it in the same turn.
- APIs are not asked this way. Another agent’s API (weather, news, a search corpus) is reached through a memory that points at it, exactly as before — An API you already run.
- The memory is kept exactly as shipped. An edited copy is put back at the next turn; the relay switch below is the only way to change it.
No relay by default
Section titled “No relay by default”A turn that is serving someone else — another agent, or an external caller — does not ask a third
agent for them. If B is answering A and B’s model tries connect-to-agent, it gets
NOT ASKED: this request came from someone else (an external caller), and connect-to-agent is set not tocontact other agents on anyone else's behalf. …and tells A that it does not relay; A can ask that agent directly. Turns for the agent’s own user (your workspace chat) are never affected.
The check is in code, not in the prompt: a turn is “for someone else” when the agent opened it through its external door, which only external callers can reach. To let one agent relay:
# in that agent's folder — its harness root: ~/.hexaeight-harness for a workspace agent,# <runner>-frontdoor/harness-root for a runner~/.heia/runtime/harness/hexaeight-engine --connector-relay on --root <harness root>~/.heia/runtime/harness/hexaeight-engine --connector-relay status --root <harness root>Relaying is also subject to the policy of every agent in the chain: B needs the rules to reach the third agent, and the third agent the rules to accept B.
An API door beside the agent’s own
Section titled “An API door beside the agent’s own”An agent that serves an API (external.api.enabled) registers that address separately, under the
api capability. A name lookup returns the agent’s normal door; the API door is returned only when it is
asked for — so an identity can hold a conversational agent and an API service at two addresses. An
API-only agent ("apiOnly": true) registers only its API door.
From the command line
Section titled “From the command line”From A’s folder:
set -a; . ./env-file; set +aexport HEIA_ASK_TIMEOUT_SECONDS=0 # wait for the work; B bounds its own turn./hexaeight-agent-linux-x64 ask-agent --peer <B> --capability <label> --ask "What is the weather in Chennai?" --jsonNo URL: A finds B in the registry by name. (Pass --peer-url https://… to skip the registry.) A backend
that holds a pipe open uses ask-daemon with the same fields, one JSON request per line.
What success looks like — on A:
[ask-agent] resolved <B> -> https://…trycloudflare.com (registry, by name)[outbound] expecting a reply from <B> on s-…{"sent":true, … "reply": … "result":"# Answer\n> Answered by PydanticAI\n\n…"}and in B’s log:
[external] <A> created session s-…[external] dde-auth request from <A> session 's-…'[/v1/messages] session ext-s-… via DDE-map -> <A> (in B's router log)Name lookup needs agent r30. Earlier agents look a named peer up by capability, and answer
cannot resolve a urlfor an agent that publishes no tag. Upgrade, or pass--peer-url.
When it fails
Section titled “When it fails”| you see | where | it means |
|---|---|---|
cannot resolve a url for '<B>' | A | B is not registered — or A’s agent predates r30; pass --peer-url |
NOT ASKED: this request came from someone else … | B’s answer | B is answering someone else and does not relay — by design. Ask the third agent directly |
agent '<B>' did not answer (denied) | A’s chat | B’s policy refused A — B lacks <A>,*,inbound,allow |
this agent serves API requests only | B’s answer | B is an API agent (apiOnly); ask a runner instead |
DENY session action from <A>: no matching allow rule | B’s log | B lacks <A>,*,inbound,allow — note the * |
NOT authorized with router …: HTTP 400, and on the router agent_jwt decrypt failed: 'r' is an invalid start of a value | B’s log / B’s router | a prerequisite, not a coordination rule: B has no model — its router does not admit B. Give it one (prerequisites) |
an answer that says a tool returned denied | B’s answer | an API agent B called lacks <B>,*,inbound / *,<B>,outbound |
{"ok":true,"accepted":"response"} and no answer | A | A and B share one identity and one store — give each its own HEIA_DIR |
Tested
Section titled “Tested”| result | |
|---|---|
| workspace chat → a PydanticAI runner, by name | answered, with live weather and news from two API agents |
| workspace chat → a LangGraph runner, by name | answered — when the runner’s policy allowed the caller; refused (denied) before |
| a new chat → the same agent | a new conversation; the earlier chat’s history not carried over |
| PydanticAI runner, answering an agent, asked to ask a LangGraph runner | declined — no relay |
connect-to-agent edited by hand | restored at the next turn |