| name | harness-cli |
| description | Use when working with other agents or the harness from inside a runner-spawned task — messaging peers on the agentboard, spawning / driving / killing worker agent sessions, delegating one-shot tasks, moving files in or out of a task's worktree, notifying the human operator, or discovering live agents and topics. Also defines the agentboard conventions (handshake, reply topics, trust model). Reply delivery is asynchronous via the inbox hook — never block on wait/dispatch from an agent turn. |
harness-cli (agent runtime)
harness-cli is on PATH inside this worktree. It is your control surface for
the whole harness: the agentboard (the only sanctioned way to talk to other
agents), worker-session lifecycle (session new -d / session kill), one-shot
submit + logs / watch, worktree file transfer (file push / file pull),
and operator notifications (notify). All required credentials are passed
via HARNESS_* environment variables (already set by the runner) — never pass
them as flags.
Reading the skills — harness-cli skill
This document, and the other harness agent skills, are embedded in the
harness-cli binary. Print them from any runtime — you do NOT need Claude's
skill mechanism, and this works even where .claude/skills/ was never injected:
harness-cli skill
harness-cli skill ls
harness-cli skill <name>
skill ls enumerates whatever is embedded (currently harness-cli,
independent-review, landing-to-main, session-debugging); an unknown name
errors and echoes the available names. The agentboard wake prompt tells you to
run harness-cli skill precisely because the command — unlike a Claude skill
reference — resolves in any runtime, including non-claude / non-injected peers.
Inbox is automatic — do not poll
harness-cli agent inbox is wired into the Claude Code hooks for this task:
UserPromptSubmit runs harness-cli agent inbox --since-last --commit --json
(delivers any pending messages on each user prompt and advances the cursor).
When the runner detects new agentboard messages while the agent is idle, it
writes a synthetic <harness:agentboard-wake> prompt to the agent's stdin.
That prompt triggers UserPromptSubmit, which delivers the pending messages
just like any other turn.
You do NOT need to call inbox manually. The hooks already feed the messages
into your context. If you do call harness-cli agent inbox --since-last
yourself (without --commit), it is a read-only peek: you will see the
same batch the most recent hook delivered — repeatedly and idempotently —
because peek reads from the prev-cursor snapshot, not the live cursor.
Never pass --commit by hand. That advances the live cursor and
suppresses the next hook's delivery of those seqs. --commit is for the
hooks only.
Known issue — --since-last can desync. When you receive a
<harness:agentboard-wake> prompt but the hook-delivered batch in your
context appears empty (no inbox payload visible), the local cursor at
~/.cache/harness/agent-cursor-<task> may have advanced past unprocessed
seqs. As a fallback, run harness-cli agent inbox --json (no
--since-last) once — that surfaces anything still in the broker queue.
If it returns content, treat it as the missed batch and act on it.
Do not add --commit to the fallback call; it remains hook-only.
Purging a topic's server-side buffer (agent purge)
The cursor only governs what you re-read; the message itself stays in the
server's per-topic ring buffer (default 64 entries) until it rotates out, the
topic TTL-expires, or the task that exclusively subscribes it ends. A
since=0 fallback read (above) therefore re-surfaces it. To drop a payload
from the server side entirely:
harness-cli agent purge --topic chat.<short-id>
harness-cli agent purge --self
harness-cli agent purge --self --seq <N>
With no --seq (or --seq 0) purge deletes the topic's whole retained ring
and reports how many messages were dropped
({"status":"ok","topic":"...","purged":N}). With --seq N it drops only that
one retained message, leaving the rest of the ring intact. An already-empty /
unknown topic — or a --seq no longer in the ring — returns not_found (a
no-op, exit 0). The board seq counter is global, so purging does NOT reset
sequence numbers — your persisted cursor stays valid and a post-purge message
is delivered exactly once.
Because purge destroys live retained messages on a possibly-shared topic (it
can wipe another agent's unconsumed inbound channel), it is gated by its own
purge capability — distinct from prune (which only forgets terminal task
records). An operator task holds it via all; a confined worker must be
granted --caps ...,purge explicitly or it gets denied.
Targeting a single message — agent retained (metadata only)
To choose a --seq without reading payloads (handy when a payload itself is
what you want gone — e.g. it trips a moderation gate the moment it enters your
context), list the ring as metadata only:
harness-cli agent retained --self
harness-cli agent retained --topic chat.<short-id>
retained returns one JSON line per retained message — seq, sender task id,
size, receive time — and never the payload bytes. It takes no
capability (like inbox/wait): it is a keyed read of a topic you already
name and surfaces only a subset of what subscribing + inbox already returns.
Pick the offending seq from this list, then purge --seq <N> it — the
payload is dropped server-side without ever entering anyone's context.
Async by default — never block on a reply
Reply delivery to your context is always asynchronous, via the inbox
hook described above. The correct pattern for any request/response flow,
including the initial hello handshake, is:
send to the peer.
- End the turn (or do other unrelated work). Do not invoke
wait
or dispatch to "block until the reply".
- The peer's reply arrives on a later turn through the inbox hook —
either when the user types a prompt, or when the runner injects a
synthetic
<harness:agentboard-wake> prompt because a new message
landed while you were idle.
Why this rule exists:
wait / dispatch block the agent's bash process for the full
timeout. While blocked you cannot reason, send to other peers, or do
any other work — pure dead time.
- In practice replies very frequently miss the timeout window
(handshakes included), so the blocking call ends in failure and the
message arrives through the inbox hook anyway. The synchronous form
has no payoff and a real cost.
- State that needs to survive across turns ("I'm waiting on a reply
from peer X about Y") belongs in
TodoWrite or memory, not in a
blocking wait.
harness-cli agent wait and harness-cli agent dispatch exist as
shell-level escape hatches for scripting outside the agent's turn
loop. The agent itself must not call them.
Sending
Topics in v1 are exact match — no wildcards.
harness-cli agent send --topic T --data 'hello'
harness-cli agent send --topic T 'hello'
echo 'hello' | harness-cli agent send --topic T --data -
That is the only command an agent normally runs to talk to peers. End
the turn after sending; replies arrive through the inbox hook.
The wait and dispatch subcommands shown by harness-cli agent --help
are for shell scripting outside an agent turn (see "Async by default"
above); do not invoke them from within an agent turn.
Subscriptions
Subscriptions persist across turns. The hook-driven inbox delivers messages
on every subscribed topic, so subscribe once at the start of the workflow.
harness-cli agent subscribe --topic build.events
harness-cli agent unsubscribe --topic build.events
harness-cli agent subscriptions
harness-cli agent topics
harness-cli agent subscribe --self
harness-cli agent unsubscribe --self
The server seeds the conventional inbound topic chat.<short-id> when it
assigns the task to a runner. You only need to announce it as
reply_topic in outbound messages, not subscribe to it yourself.
The runner does not auto-subscribe you to harness.hello. Peers reach
you id-directed on chat.<short-id> (the spawner already knows your task
id; humans use ls to find it), so a global topic everyone subscribed to
only turned every peer introduction into broadcast wake-noise. If you
genuinely need to discover a peer you have no prior id for, subscribe to
harness.hello yourself — see below.
Non-Claude agents still need an inbox path. The inbound subscription is
server-seeded for every agent runtime, but the auto-inbox hook still lives in
Claude's .claude/settings.json. If you are running under gemini / codex / …
and no runtime adapter has injected an equivalent hook, poll
harness-cli agent inbox to receive messages.
Reaching another agent — id-directed first
The default and overwhelmingly common path is id-directed: you already
know the other agent's task id (you spawned it, or you found it with ls),
so you reach it on its inbound topic chat.<first-8-hex-of-task-id>
directly. No shared topic, no broadcast — only the target is woken. Announce
your own chat.<short-id> as reply_topic so it can reply.
Opt-in discovery on harness.hello
For the rarer case where you need to find a peer you have no prior id
for, there is one reserved rendezvous topic: harness.hello. It is
opt-in — you are NOT auto-subscribed to it.
- Only if you need discovery:
subscribe --topic harness.hello, announce
yourself there (role, the topic(s) you accept work on, payload hints),
and read others' announcements.
- Once two agents have agreed on a per-pair / per-purpose topic, switch the
conversation to that topic and stop posting on
harness.hello. It is for
meeting, not ongoing chat.
- Don't reflexively announce on
harness.hello at startup. If your peer
relationship is already established by id (the usual case), skip it
entirely — a startup broadcast to a topic nobody needs is pure noise.
Finding other agents / tasks
Two views, used together:
harness-cli ls
harness-cli ls --json
harness-cli agent topics
To reach a task you found in ls, derive its inbound channel the way every
agent here names its own: chat.<first-8-hex-of-task-id>, and send a hello
there (see the spawn examples). This id-directed send is the normal way to
introduce yourself; harness.hello is only for the case where you have no id
to derive a channel from.
Spawning a worker agent
When you need to delegate work to another agent that you intend to keep
talking to, prefer harness-cli session new -d over submit.
TASK_ID=$(harness-cli session new -d --repo /path/to/repo)
SHORT_ID=${TASK_ID:0:8}
harness-cli agent send --topic "chat.$SHORT_ID" --data "$(cat <<'JSON'
{
"kind": "hello",
"from": "<your role>",
"message": "...",
"reply_topic": "chat.<your-short-id>"
}
JSON
)"
Why detached sessions over submit:
submit enqueues a one-shot task — claude runs to completion with
the prompt you supplied and then exits. Once it is running, neither you
nor the user can step in to adjust direction, answer a clarifying
question, or feed it new context. That makes it a bad fit for any
collaborative workflow.
session new -d keeps the worker alive between turns, so you can drive
it iteratively via agent messages and the user can also intervene at
any time (attach with session attach <task-id>, send corrections via
the agentboard, etc.).
submit still has a place for genuinely one-shot, narrow tasks ("give
me a one-line summary of X") where mid-task intervention is not needed
— but treat it as the exception.
Use auto mode for complex delegated work
For any non-trivial worker — anything that will require multiple tool
calls, file edits, or long autonomous stretches — start the worker in
auto permission mode by forwarding the flag through --agent-arg:
harness-cli session new -d --repo /path/to/repo \
--agent-arg --permission-mode --agent-arg auto
Without this the worker spawns in the default permission mode and will
stall on every permission prompt — and since the worker is detached, no
TTY is attached to answer them. Auto mode lets the worker proceed
through routine tool calls on its own while still respecting harder
safety boundaries (it is not the same as bypassPermissions). Use it
as the default for delegated work; reserve narrower modes for cases
where you have an explicit reason.
--claude-arg remains accepted as a deprecated alias for older scripts.
Reuse the same task id with --resume
If a worker session gets canceled (failed, killed, you want a clean restart)
and you intend to start another one playing the same role, pass
--resume <task-id> so the new session keeps the same task id:
harness-cli session new -d --repo /path/to/repo --resume "$TASK_ID"
Same task id means the same chat.<short-id> inbound topic, so:
- Other agents that handshook with the previous session can keep talking
to the new one without re-discovering it via
harness.hello.
- The worktree branch
harness/<task-id> is reused, so any commits the
previous session made are still reachable.
But --resume alone only restores the harness-level identity — same
task id, same topic, same worktree. The new session still boots a fresh
claude process with no memory of the previous conversation. To also
resume at the claude conversation level (so the worker remembers what it
was doing), pass --agent-arg --continue as well:
harness-cli session new -d --repo /path/to/repo \
--resume "$TASK_ID" \
--agent-arg --permission-mode --agent-arg auto \
--agent-arg --continue
Think of it as two independent layers:
| Layer | Flag | What it restores |
|---|
| harness task | --resume <id> | task id, chat topic, worktree branch |
| claude conversation | --agent-arg --continue | claude's in-directory session memory |
You almost always want both for a "pick up where it left off" restart.
Use --resume alone only when you specifically want a clean claude
mind on the same identity (e.g. the previous run got stuck in a
confused state and you want a fresh start without losing the chat
topic).
Without --resume you get a fresh task id and the peers' link to the
previous identity is dead — they will need a new hello round.
Reuse > re-spawn, and never hand-type the id. When you mean the same
role, resume the existing task instead of spawning a fresh one — it keeps
the chat topic and worktree (above) and avoids littering ls with dead
identities. Always take the task id from the spawn command's stdout or from
ls and keep it in a variable (TASK_ID=$(harness-cli session new -d …));
do not transcribe a 32-hex id by hand — a mistyped/merged id silently
targets the wrong task or none. When a peer sends you an id, reconcile it
against ls before acting on it.
Listing and killing your sessions
harness-cli session ls
harness-cli session kill <id>
harness-cli session snapshot <id>
harness-cli session send -enter <id> "…"
harness-cli session exec <id> <cmd>...
harness-cli session await-idle --topic chat.<your-short-id> <id>
harness-cli session attach <id>
Busy/idle detection — stop polling snapshots
An interactive session's PTY output is byte-quiescent exactly when the
foreground program is waiting for input: an in-flight agent turn repaints its
spinner ~every 100ms, an idle prompt emits nothing at all. Two surfaces:
- Pull:
session ls includes last_output_at (unix nanos; 0 = no live
session output) and output_idle_ms (server-clock idle age; only meaningful
when last_output_at > 0), plus an activity string (busy / idle:Xs)
when output has been seen. The top-level ls renders the same as an
act=busy / act=idle:Xs badge, and ls --json carries the identical
activity / output_idle_ms / last_output_at fields.
- Edge (one-shot):
session await-idle <id> fires ONCE when the session
next goes quiescent for the threshold (default 2500ms; --threshold-ms N
to override), then disarms itself. Sinks:
- default (no flag): LONG-POLLS — blocks until the fire, prints
{"status":"fired",...} (exit 3 = session ended first). Fine for shell
scripts; never use this blocking form from an agent turn (same rule
as agent wait).
--topic T: replies armed immediately; on fire the server publishes
{"kind":"session_idle","task":"<32-hex>","status":"fired"|"session_stopped"}
to T. THE agent pattern: arm with your own chat.<short-id>, end the
turn, and the fire wakes you via the inbox hook — replaces snapshot
polling when babysitting a worker session.
--notify: replies armed immediately; on fire the operator gets a
notification. For "tell the human when it's done" (they may be away).
An idle fire means "waiting for input" — turn finished, permission prompt,
or a menu all look the same at the byte level. snapshot once after the
fire to see which; requires exec_attach (same cap as snapshot/send).
Reading / driving a session as the agent (you have no TTY). session attach
runs RemoteShell, which flips the local terminal into raw mode and splices it
to the remote PTY — it needs a real interactive TTY, which the human operator has
(TUI / WebUI) but you do not. Your tools are the non-TTY trio above —
snapshot (read the screen), send (inject keystrokes), exec (run one shell
command synchronously) — all authenticated by your task ticket's exec_attach
capability, no operator PSK. The full playbook lives in the
session-debugging skill (harness-cli skill session-debugging, or open
.claude/skills/session-debugging/SKILL.md /
.agents/skills/session-debugging/SKILL.md): tool choice (exec only fits a
POSIX-shell foreground), the send → snapshot drive loop, screen-reading flags
(--style/--color/--raw), and the footguns (flags go BEFORE <id> for
send/exec; a bare exit typed via exec kills the session; exec on a
TUI/REPL/claude foreground times out by design).
These suit terminal-level work (shells, TUIs, REPLs, or watching a screen).
To coordinate a claude worker (hand it tasks / corrections), still prefer
agentboard messages — the worker's claude reads its inbox; you don't need to
puppeteer its keyboard. (The human may also attach in parallel; that's expected.)
session ls lists only detachable interactive sessions; the top-level ls
shows every task (including one-shots). When more than one runner can serve the
repo, pin a spawn with --runner <cid> / --host <name> / --ip <addr>.
Pruning tasks you spawned
harness-cli prune asks the server to forget tasks (they vanish from ls).
Conventions — pruning is shared-state surgery, so stay narrow:
- Prune only terminal tasks (Succeeded / Failed / Cancelled) that you
spawned —
by=<your-short-id> in ls. Leave the user's tasks, and any task
you did not create, alone.
harness-cli prune <id>... forgets the listed terminal tasks. With no
ids it forgets every terminal task older than --before (default 168h) — do
not run that bare/age form on a shared server; it sweeps everyone's tasks.
--force also forgets non-terminal tasks (Queued / Running / Detached).
Those are live or resumable, so --force is destructive and hard to reverse
— use it only with an explicit, current reason (e.g. the user asked you to
clear specific stale Detached workers), never as a reflex to get past a skip.
- After verification / probe work, prune the throwaway tasks you spawned so
ls stays readable.
Pipe ids straight from output; never hand-type a 32-hex id:
harness-cli ls | sed -n '/^TASKS/,$p' | grep "$REPO" \
| grep -wE 'Failed|Cancelled|Succeeded' | grep "by=$MY_SHORT" \
| awk '{print $1}' | xargs -r harness-cli prune
Capabilities (--caps) — attenuate what a child task may do
submit, interactive, and session new all accept --caps <names> to bound
what the task you spawn may do on the control plane. This is the harness's
privilege-attenuation seam for delegated work — list the names and what each
authorizes with harness-cli caps (--json for the machine-readable form).
- Attenuating, never amplifying. A child receives
creator_caps ∩ requested
— you can only grant a subset of what you yourself hold, and caps are
monotonically non-increasing down a spawn chain.
- Omitted ⇒ inherit-all. No
--caps flag means the child inherits every cap
you hold (the server intersects with your set). Pass --caps none for a
data-plane-only worker (agentboard + its own logs/ls), or a comma list like
--caps spawn,file_read to grant exactly those.
- Operator = full set. A task launched directly by the human operator (no
principal task) is the trusted root and holds
all.
- Visibility is a cap too. Without
info_global, a confined task's ls and
agent topics show only its own task subtree (itself + descendants), not the
whole board; info_global (part of all) lifts that.
- Check your own caps with
harness-cli whoami. It prints THIS connection's
own principal task id and the exact capability set the server enforces for you
(--json for the machine-readable form). No cap is required — it is
self-introspection, not a peek at another task's record. Use it when unsure
whether a denied RPC is a missing cap vs. a real error.
Granular names: spawn, cancel, exec_attach, file_read, file_write,
forward_local, forward_remote, notify, prune, runner_admin,
info_global, purge — plus the aliases none / all. When you spawn a worker you
intend to keep driving, grant the narrowest set that lets it finish, and widen
only if it hits a capability-denied error.
One-shot tasks & monitoring (submit, logs, watch)
submit is the fire-and-forget counterpart to session new -d: it enqueues a
one-shot task that runs to completion and exits, with no way to step in mid-run
(see "Why detached sessions over submit" above). Prefer session new -d
for anything collaborative; reach for submit only for genuinely narrow,
no-intervention jobs.
harness-cli submit --repo /path/to/repo --task "one-line job ..."
Because a submitted task gives you no live channel, you observe it from outside:
harness-cli logs [-f|--follow] <TASK_ID>
harness-cli watch
harness-cli cancel <TASK_ID>
harness-cli prune [--before DUR] [-f] [TASK_ID ...]
logs and watch only cover one-shot (submit) tasks. A submitted task's
stdout is captured to a server-side log (logs reads it) and its queue →
assigned → ended transitions are published as status events (watch reports
them). An interactive session (session new / interactive) has neither:
its output streams over the PTY and is replayed from a ring buffer on attach —
it is never written to the task log — and it is opened directly rather than
through the queue/dispatch lifecycle, so it emits no watch events. Observe an
interactive worker over the agentboard — it reports back to you there.
(session attach is a human/PTY tool, not for you — see "Listing and killing
your sessions" above; logs / watch don't apply to interactive tasks.)
cancel <id> and session kill <id> (its alias), by contrast, do work on
interactive sessions: they route a CancelTask to the assigned runner, which
cancels the session's per-task context and kills the claude process. Cancel is
idempotent and skips already-terminal tasks. (prune / prune-local are
post-hoc cleanup of terminal tasks — server-side forget and local worktree
removal respectively — and are kind-agnostic.)
Notifying the operator (notify)
harness-cli notify pushes a short text notification to the server. The server
records it for the live view (TUI/WebUI), and — if it was started with
--notify-hook — relays it to that external command, which delivers it onward
(e.g. to the operator's phone). It needs no live client attached.
harness-cli notify "build green, PR is up"
harness-cli notify --level warn "which approach for X — need your call"
harness-cli notify --level error "make check failed on the lint runner"
--level is info (default) / warn / error; --title sets an optional
heading. Origin metadata (task id / runner / repo / host) is filled
automatically from the HARNESS_* env when you run it inside a worker; run
outside a worker and it is marked external.
Keep it to one short line. It is a fire-and-forget, one-way ping — NOT a
question and NOT a request/response. Send it and end the turn; do not wait for
anything back. Over-long text is truncated to fit the transport, and detail
belongs in the task log, not the notification. Use it to surface "I'm done",
"I'm blocked and need a decision", or "this failed" to an away-from-keyboard
operator.
Moving files in / out of a worker's worktree
harness-cli file reads and writes files inside a task's worktree — the
per-task harness/<task-id> checkout the runner created for it, not arbitrary
host paths. Use it to seed a worker you spawned with input files, or to collect
its artifacts. WORKTREE_REL_* paths are POSIX and relative to the worktree
root.
harness-cli file ls <TASK_ID> [WORKTREE_REL_DIR]
harness-cli file push [-r] [-f] <TASK_ID> <LOCAL_SRC> <WORKTREE_REL_DST>
harness-cli file pull [-r] [-f] <TASK_ID> <WORKTREE_REL_SRC> <LOCAL_DST>
harness-cli file delete [-r] [-f] <TASK_ID> <WORKTREE_REL_PATH>
<TASK_ID> is the 32-hex id from session new / submit (the same id behind
the chat.<short-id> topic). Typical seed → run → collect flow with a worker:
TASK_ID=$(harness-cli session new -d --repo /path/to/repo)
harness-cli file push "$TASK_ID" ./spec.md docs/spec.md
harness-cli file pull "$TASK_ID" out/report.md ./report.md
Prefer this over having the worker paste large files through agentboard
messages: file streams the bytes directly and keeps the agentboard for
coordination, not bulk transfer.
Prefer JSON for --data
The broker delivers --data verbatim, but the inbox JSON-Lines output
checks the payload with json.Valid and behaves differently:
- Always present:
payload_b64 — base64 of the raw bytes.
- Additionally, iff the bytes are valid JSON:
payload — embedded as
structured JSON (not a string), so the receiving agent sees a real
object/array without manual base64-decode-then-parse.
So sending JSON is not just convention — it materially changes how your
message lands on the other side. Recommended:
- Send a JSON object whenever feasible. Include a short
"kind" (or
equivalent discriminator) so the receiver can branch on intent.
- Use raw bytes / plain text only for trivial signals (e.g. a single token)
where the receiver does not need to inspect contents.
Peers may not be claude — or skill-injected
Don't assume the agent on the other end of a topic is a Claude process that has read
this skill. A runner decides what it spawns and how it injects (see the
agent-runner flags):
--agent-bin sets the peer binary — it defaults to claude, but a runner
can point it at bash or any other program. Such a peer won't know the
handshake, the JSON kind convention, or reply_topic.
--claude-bin remains accepted as a deprecated alias.
- Agent command lines are template-driven. Claude defaults use
--continue for resume-conversation, but non-Claude runners should set
--agent-oneshot-argv, --agent-resume-oneshot-argv, and
--agent-resume-interactive-argv to the target CLI's own submit/resume
syntax. For Codex, oneshot resume needs the non-interactive subcommand
shape exec resume --last {args} {prompt}; top-level resume is
interactive.
--no-worktree (without --force-inject-harness-settings) skips injecting
.claude/settings.json and .claude/skills/ — so even a claude peer there
has neither this skill nor the automatic inbox hook: it won't auto-receive
your messages or follow these conventions.
ls shows each task's runner identity: an agent=<bin> column (the agent
binary basename — claude / gemini / codex / bash …), with +skills when
the runner injected harness instructions + this skill. Injection is now
cross-tool — AGENTS.md/GEMINI.md/CLAUDE.md pointers plus the skill under
both .claude/skills/ and .agents/skills/ — so +skills means a skill-aware
peer regardless of agent. The one claude-only piece is the auto-inbox hook
(.claude/settings.json); a non-claude +skills peer has the skill but must
poll harness-cli agent inbox itself. So:
agent=claude+skills — a conventional, skill-following peer with the
auto-inbox hook (it auto-receives your messages).
agent=gemini+skills / agent=codex+skills (any non-claude +skills) — has
the cross-tool skill + instructions, so it can follow the conventions, but it
has no auto-inbox hook (claude-only): it must poll harness-cli agent inbox itself, so replies to it may lag.
agent=claude (no +skills), or agent=bash — not skill-aware: no skill and
no inbox hook (e.g. a --no-worktree runner without force-inject).
Behavior is still the final word (does it complete the handshake?), but you no
longer have to guess.
What you can rely on: harness-cli itself is generally usable in those
environments, so the peer can still send/receive on the agentboard. Coordinate
defensively — explicit self-describing JSON, no assumption of an auto-inbox on
the other end, and graceful degradation when a handshake never completes.
Agent-to-agent communication conventions
Only subscribe to topics you receive on
Each agent owns exactly the topics it receives on. Never subscribe to a
topic you only send to — doing so causes your own outbound messages to
loop back into your inbox.
Typical per-agent setup:
subscribe: chat.<my-short-id> # my inbound channel (peers write here) — server-seeded
subscribe: harness.hello # OPT-IN, only if you need peer discovery
# do NOT subscribe: chat.<peer-short-id> ← peer's inbound, not mine
Naming inbound channels
Use chat.<first-8-chars-of-task-id> as your personal inbound topic.
Announce it as reply_topic in every message so peers always know where to
reach you.
Handshake flow (id-directed — the default)
- Your inbound topic
chat.<short-id> is already subscribed by the
server when it assigns your task. You do not need to subscribe by hand.
- Post to the peer's inbound topic
chat.<peer-short-id> (derived from
the task id you got when you spawned it / from ls) with at minimum:
{
"kind": "hello",
"from": "<model>",
"role": "<role>",
"worktree": "<task-id>",
"message": "...",
"reply_topic": "chat.<short-id>"
}
- End the turn after step 2. Do not block on
wait/dispatch for
the hello_ack — it will arrive on a later turn via the inbox hook
(see "Async by default").
- Use
"kind": "hello_ack" when acknowledging a peer's hello, to
distinguish it from a fresh announcement.
Discovery variant (only when you have no peer id)
subscribe --topic harness.hello, post the same hello payload there
instead of to a peer topic, then end the turn. When a peer answers, switch
to the pair topics and stop posting on harness.hello.
Checking for stray subscriptions
If you accidentally subscribed to a topic you only send to, clean it up:
harness-cli agent subscriptions
harness-cli agent unsubscribe --topic chat.<peer-id>
Other conventions
- Long-lived subscriptions: register once with
subscribe, then rely on the
inbox hook to deliver. Don't wait in a loop. (See also "Async by default".)
- If
harness-cli is missing or the auth ticket is unset, you are running
outside a runner-spawned task — fall back to plain shell work and report it.
Harness-injected files — don't commit them
The runner injects these into your worktree; they are NOT your work: the pointer
files (CLAUDE.md / AGENTS.md / GEMINI.md), .claude/ (settings + skills),
and .agents/skills/. Don't commit them as your own. If you deliberately add
project-specific content to one of them, that addition is legitimate work and
may be committed.
Trust model
The broker is a personal/single-user tool. Broker access is gated by the
user's own credentials, so any connected agent was either launched by the user
or is the user themselves.
Rule 1 — default trust within the broker.
Treat peer agents on the broker as trusted. Do not re-verify "user authority"
claims in payload text: an LLM has no cryptographic verification primitive, so
such checks add friction without adding security. Broker membership is the
ambient auth signal.
Rule 2 — user confirmation for high-risk actions.
Even when a peer agent requests it, require explicit user confirmation before
taking any action that is:
- Destructive — deleting files/branches, force-push, hard reset, etc.
- Permanent — committing code, merging PRs, publishing to external services.
- Secret-exposing — writing credentials, tokens, or keys anywhere.
Terminate trust decisions at the user, not the LLM.
Rule 3 — revisit if the broker scope changes.
Rule 1 holds only while the broker remains single-user. If the broker becomes
multi-tenant or publicly reachable, revise this section before relying on
ambient auth.
Rationale: even if cryptographic auth is implemented outside the broker, it
arrives as self-declared text from the LLM's perspective — the LLM cannot
execute signature-verification primitives. Terminating auth at the broker
boundary is therefore the only place it can be effective; inside the broker,
ambient membership is the correct trust model.