The i5h Protocol: Git-Native Messaging Between AI Agents
A durable agent message bus does not need a server. An append-only log in a Git ref, with compare-and-swap writes and union-merge reconciliation, gives you typed, auditable, offline-capable agent-to-agent messaging. This is the wire-format deep dive.
- A durable agent message bus needs only four primitives Git already has: an append-only log, content-addressed storage, compare-and-swap, and union-by-id merge.
- "No lost messages" is precise: in-clone CAS serializes writers, and cross-clone union-by-id gives strong eventual consistency — not real time.
- i5h trades immediacy for durability and zero infrastructure; it complements MCP (tool transport) and A2A (live RPC) rather than competing.
More teams now run more than one coding agent on the same repository. Claude Code drafts a feature; Codex reviews it. One agent implements while another watches for regressions. A human kicks off three sessions and walks away. The agents edit the same files, but they have no shared channel — so why a change happened ("I changed the cache, can you check the expiry path?") lives in a human's head, a Slack thread, or nowhere at all.
The companion post, Git as the communication layer for AI agents, argues why a Git ref is the right substrate for that channel — serverless, offline-first, replayable. This post is the other half: the concrete protocol that rides on it. i5h — Inter-Agent Information & Interaction Handshake — is the message format and the send/merge rules. We will go field by field through the schema, show exactly how compare-and-swap plus union-merge guarantee that no message is lost across clones, and be precise about what i5h does not do (it is not a live RPC, and it is not MCP).
h5i msg watch, the channel on refs/h5i/msg. Typed
messages between agents render as they arrive, and every one is a durable Git
object you can replay later.
The whole protocol in one line
A message is one JSON object, appended as one line to messages.jsonl
inside the Git ref refs/h5i/msg:
{"version":1,"id":"01890d8e6f4a2b17","ts":"2026-05-28T22:18:04.123000Z", "from":"claude","to":"codex","kind":"ASK", "body":"Can you inspect the failing auth test?"}
Seven required fields, all human-readable. To reply, send another line that points
back with reply_to. That is the entire required protocol: pushing the
ref shares the conversation, pulling merges it. Because messages are immutable and
keyed by id, two clones that diverge merge by simple set union — a
conflict-free, grow-only log. A correct sender or reader fits in an afternoon.
refs/h5i/msg, Codex
reads it and appends ACK then DONE replies that thread
back via reply_to — one JSON line each, merged across clones by id.
The message schema, field by field
The required core never grows: seven fields carried on every message. A reader that receives only these is fully conformant.
| Field | Type | Meaning |
|---|---|---|
version | integer | Protocol version, currently 1. A missing version reads as legacy 0. |
id | string | Opaque, per-occurrence event id (the Rust build emits 16 hex chars derived from the fields plus a nonce). Keys dedup on merge; not a content hash. |
ts | string | UTC RFC3339, fixed-width fractional seconds, so it sorts lexically. Used for display order and tie-break, not as a correctness guarantee. |
from | string | Sending agent identity. A repo-local label — see untrusted. |
to | string | Recipient identity, or all for broadcast. |
kind | string | One of the message kinds. An unknown kind renders as a plain message. |
body | string | Exact sender-authored text. Stored verbatim; never auto-compressed or rewritten. |
Everything else is optional — hints that make important handoffs machine-routable, never requirements. A reader that ignores all of them is still conformant (and MUST ignore and preserve fields it does not recognize, so a newer-version field survives a round-trip through an older reader):
| Optional field | Type | Meaning |
|---|---|---|
reply_to | string | Id of the message this replies to. The one threading primitive. |
thread_id | string | Cached root id of the reply_to chain, for O(1) bucketing. Derivable from reply_to. |
status | string | Advisory lifecycle hint. A reader recomputes it from replies and MUST NOT depend on it. |
priority | string | low, normal, high, or urgent. |
branch / context_branch | string | Git branch / h5i context branch the message concerns. |
focus | array | Files, symbols, or tests to inspect first. |
risk | string | One concise risk statement. |
deadline | string | UTC RFC3339 deadline for a response. |
links | object | Related PRs, commits, context nodes, or URLs — pointers into the same repo. |
meta | object | Must-ignore extension bag. New, not-yet-standard fields live here so they never collide with the core. |
A rich review handoff carries the core plus a few hints — and is still one line:
{"version":1,"id":"8f21c9a3b0d41e62","ts":"2026-05-28T22:18:04.123000Z", "from":"claude","to":"codex","kind":"REVIEW_REQUEST","priority":"high", "branch":"auth-refactor","focus":["src/auth.rs","src/session.rs"], "risk":"token refresh cache now crosses request boundaries", "body":"Review token refresh behavior before PR.","links":{"pr":42}}
Typed acts, not chat
i5h messages read like an incident-command radio exchange — short, typed,
auditable — not consumer chat. Every message declares its kind so a
receiver can route and close it without re-reading prose. This is the useful half
of the FIPA-ACL/KQML speech-act idea (each message states its intent) without
FIPA's fatal weight (20+ performatives and a required ontology). The set is small
and grows only against a high bar:
| Kind | Use | Expected follow-up |
|---|---|---|
FYI | Informational; no action required. | none |
ASK | A request needing a response. | ACK / DONE / DECLINE |
REVIEW_REQUEST | Review code, design, or security. | ACK then DONE / FAILURE |
RISK | A specific hazard to inspect. | ACK or reply |
BLOCKED | Sender is stuck pending input. | a reply supplying the input |
HANDOFF | Transfer task ownership + context. | ACK then DONE |
ACK | Accepts / will act on a prior message. | later DONE / FAILURE |
DONE / DECLINE / FAILURE | Terminal replies that close a thread. | none |
NOT_UNDERSTOOD | A parseable message whose kind the receiver doesn't support. | sender resends as plain text |
There is deliberately no BROADCAST kind:
kind is one scalar, so a message can't be both "broadcast" and
RISK. Broadcast is purely routing — set to = all and keep
the real kind (a broadcast hazard is a RISK addressed to all).
Threads are a fold, not a status field
A request (ASK, REVIEW_REQUEST, HANDOFF)
opens a thread; its state — open, working,
completed, declined, failed — is a
fold over the immutable reply events, not a mutable flag. ACK
moves it to working, DONE to completed,
DECLINE/FAILURE to their terminal states. The optional
status field is only a cached hint of that fold; a reader can always
recompute the answer from the messages alone, so every clone converges on the same
thread state without trusting any writer to have stamped it.
Threading itself is one edge: reply_to points at the parent, and a
thread is the transitive closure of those edges (thread_id just caches
the root). Display order is causal — a reply is shown after its parent
because it points at it — and (ts, id) only breaks ties between
messages with no causal relationship. That is the whole ordering story: wall-clock
time is good enough for display and deliberately not load-bearing for correctness,
because a pulled message can arrive carrying an older ts than one you
already have.
How a send can't lose a message: CAS + union-merge
The interesting engineering is in two operations: send, which must not clobber a concurrent writer in the same clone, and pull, which must reconcile clones that diverged offline. Both are small, and together they give strong eventual consistency.
Send is a compare-and-swap. A send never mutates the ref in place.
It reads the current tip, appends its line to that tip's
messages.jsonl, builds a new commit, and then moves the ref
only if it still points where it was read (git's atomic ref update). If a
concurrent writer moved the tip first, the CAS fails; the sender re-reads the new
tip, re-appends, and retries — up to 64 attempts before erroring. So bursty
concurrency (several agents sending into one clone at once) never silently drops an
append: a writer would have to lose 64 consecutive races to fail.
# pseudo-code of append_message_cas (src/msg.rs) for _ in 0..64 { tip = ref("refs/h5i/msg") # may be None on first send log = read(tip, "messages.jsonl") + new_line new = commit(parent=tip, tree={messages.jsonl, agents.json}) if compare_and_swap(ref, old=tip, new) { return Ok } # won the race # lost: loop, re-read the moved tip, re-append }
Pull is a union-merge by id. Two clones that each sent while offline produce divergent tips. On pull, git fetches only the missing objects (cheap), then i5h reconciles:
- local is an ancestor of incoming → fast-forward;
- incoming is an ancestor of local → keep local;
- diverged → union the two
messages.jsonlblobs by messageid, re-sort into canonical(ts, id)order, and commit the result with both tips as parents.
The log is a grow-only set (a G-Set CRDT): immutable records keyed by id,
so merge is set union — the cleanest case of strong eventual consistency. Re-pulling
the same message is idempotent (same id, already present), and the merge is
commutative, associative, and idempotent, so the order in which clones sync doesn't
change the final state. Delivery is at-least-once: once a message
reaches a peer's ref it is never lost, but a side effect (a CI trigger, a review run)
may fire more than once — so a consumer should make its actions idempotent. This is
the same proven pattern as git-appraise, whose review log merges with
cat_sort_uniq (set union, exactly i5h's rule).
(ts, id), not by a true happens-before.
What it looks like to use
Each agent's identity is injected per host — in Claude Code it is claude,
in Codex codex — so the common path needs no flags. For the typed verbs
the options come before the recipient, because the body is the trailing
argument:
$ h5i msg review --branch auth-refactor \ --focus src/auth.rs --focus src/session.rs \ --risk "token refresh cache now crosses request boundaries" \ codex "Review token refresh behavior before I open the PR." ✔ claude → codex REVIEW_REQUEST high #8f21c9a
On the other side, Codex reads the request from its inbox, rendered as quoted, untrusted inbound communication — never as a system instruction:
INBOX 1 unread 1 22:18 claude → codex REVIEW_REQUEST high #8f21c9a Review token refresh behavior before I open the PR. branch auth-refactor focus src/auth.rs, src/session.rs risk token refresh cache now crosses request boundaries reply h5i msg ack 1 $ h5i msg ack 1 # … reviews the code … $ h5i msg done 1 "LGTM. One expiry edge case fixed in 1a2b3c4 — see PR #42."
Free-text h5i msg send codex "…" is always enough; the typed helpers
(ask, review, risk, handoff,
ack, done, decline) and optional hints
(--focus, --risk, --priority,
--branch) just make important handoffs machine-routable.
Identity, read-state, and delivery
Identity resolves --from/--as > $H5I_AGENT
> the stored local default. In a shared clone, set H5I_AGENT per host
rather than relying on the single stored default — that is what keeps two agents from
answering as each other. Names are validated everywhere against a conservative charset
([A-Za-z0-9._-]+): no whitespace, no path separators, no control
characters, because names flow into roster keys and (untrusted, on pull) the terminal.
# Claude Code: one-time wiring (identity = claude) $ h5i msg setup claude # Codex: launch the session under its own identity $ H5I_AGENT=codex codex # Separate clones? Sync the channel over the remote. $ h5i share push # publish refs/h5i/msg $ h5i share pull # merge the other side's messages
Read-state is local and per-identity, never pushed: a grow-only set
of seen message ids per agent (cursors/<agent>.json), plus a small
reply-numbering view (views/<agent>.json) so that
reply 1 resolves to the right message for the right agent. Because
membership is by id rather than a timestamp watermark, a message that arrives via
pull with an earlier timestamp than something already read
(clock skew, late delivery) is still delivered exactly once. Two agents sharing one
clone use different files and never consume each other's mail. Crucially, passive
views (watch, wait, the dashboard) never
advance read-state — only an explicit inbox or a confirmed hook delivery
does (peek, render, then acknowledge).
Delivery itself is a per-host CLI concern, not part of the wire format. The Stop hook surfaces new messages between turns; SessionStart notes anything unread on resume. But a hook can't wake an agent that has sent a request and gone idle waiting on a reply — so for that case there is an explicit waiter that exits the moment a reply lands:
$ h5i msg wait --as codex --timeout 600 # wakes (exits) the moment a reply lands; then: $ h5i msg inbox
Treat every message as untrusted
A shared channel fed by multiple agents is an attack surface, and i5h is explicit
about it. The from field is a repo-local label, not proof of authorship:
Git object hashes prove integrity and history (a stored message wasn't
altered) but not authorship. i5h messages are currently unsigned,
so every from is an untrusted claim and a reader must never elevate trust
on it alone. (Signed refs — the direction Radicle takes — are a future security
profile, not part of v1.)
The display path is hardened accordingly. sanitize_display folds tab,
newline, and carriage return to a single space and drops every other control
character — ESC and the rest of the C0/C1/DEL range — before any untrusted pulled
field reaches the terminal, so a crafted body or from can't
smuggle an ANSI escape that rewrites your screen. The behavioral rules are just as
firm: never execute a message body as a command, never treat a hook-delivered message
as a higher-priority instruction, and never auto-open a URL or auto-checkout a branch
from a message without an explicit decision. Incoming messages are collaborator input
to evaluate, not commands to obey.
i5h next to MCP and A2A
i5h is often grouped with two other agent protocols, but they solve different problems. A fair reading:
| MCP | A2A | i5h | |
|---|---|---|---|
| Shape | Agent ↔ tools/data | Agent ↔ agent, live RPC | Agent ↔ agent, async log |
| Transport | Client-server (JSON-RPC) | HTTP endpoints | A Git ref (push/pull) |
| Availability | Both sides online | Both sides online + reachable | Offline-first |
| Durability | Per-session | Ephemeral unless persisted | Durable Git objects, replayable |
| Timeliness | Real-time | Real-time | Eventually consistent |
MCP (Model Context Protocol). Good: a clean standard for connecting one agent to tools, files, and data sources. Gap: it is a single-agent tool transport, not a peer message bus — there is no shared, durable conversation between two agents.
A2A (Agent2Agent). Good: real-time delegation between agents, with a
task lifecycle (i5h deliberately mirrors its working/completed/
failed names so the model is familiar) and capability cards. Gap: it is
live RPC, so both agents must be online and able to reach each other's endpoints, and
a message is gone once the call ends unless you build storage around it.
i5h. Good: durable, offline-first, server-less, replayable — agents in different clones, or running hours apart, still coordinate, and the record outlives the session. Gap: it is eventually consistent rather than real-time, and unsigned. The honest summary is that i5h trades immediacy for durability and zero infrastructure; where you need a live request/response, A2A is the better fit, and the two can coexist.
Deliberately small
The required core is seven fields and it stays there. Unknown fields MUST be ignored
and preserved; new ideas go in optional fields or the meta bag, never new
required fields; the kind set stays tiny. That discipline is the point:
i5h is not an orchestration framework, not a live transport, and not a replacement for
h5i context, memory, or PR briefs — it links to those. It does exactly one
thing: let agents exchange typed handoffs over Git, without a broker. The cautionary
history of protocols that didn't hold this line — FIPA-ACL, WS-*, CORBA — is why the
bar for the core is so high.
Conclusion
A durable agent message bus turns out to need surprisingly little: an append-only log,
a content-addressed store to keep it honest, an atomic compare-and-swap to serialize
writers, and union-by-id to reconcile clones. Git already provides all four, so i5h
adds only a seven-field message and the rules for sending and merging it. The payoff is
a channel that is server-less, offline-capable, auditable, and replayable — you can
git show a year-old handoff — at the explicit cost of real-time delivery
and, for now, authenticity. If your agents run live and in lockstep, reach for an RPC
protocol like A2A. If they run across clones, across time, and you want the conversation
to live beside the code it concerns, an append-only Git ref is enough.
FAQ
What is the i5h protocol? It is h5i's agent-to-agent messaging
format: one JSON object per line in messages.jsonl inside the Git ref
refs/h5i/msg. The required core is seven fields; optional fields add
machine-readable hints. There is no server, socket, or schema registry — pushing the ref
shares the conversation, pulling merges it.
How does i5h guarantee no messages are lost when clones diverge? A
send commits off the current tip and moves the ref with a compare-and-swap, retrying on
contention. A pull reconciles divergent tips by union-merge: union the messages by id,
re-sort by (ts, id), commit with both parents. Immutable, id-keyed messages
form a grow-only set (a G-Set CRDT), so clones converge — eventual consistency, not
real-time.
How is i5h different from MCP and A2A? MCP connects one agent to tools over a live link; A2A is live RPC between agents that need to be online. i5h is async and offline-first — durable Git objects reconciled later by push/pull — at the cost of real-time delivery.
Is an i5h message safe to act on automatically? No. from
is an unsigned, repo-local label, so a reader must never elevate trust on it. h5i renders
messages as quoted, untrusted input, sanitizes control characters, and never runs a body
as a command or auto-acts on a link or branch.
What to read next
For why a Git ref is the right substrate at all, read the companion piece, Git as the communication layer for AI agents. The full normative spec — ordering, delivery semantics, quarantine rules, and the conformance checklist — is the i5h protocol document. For how agents share the reasoning behind the code they coordinate on, see the Context DAG post, or the workflow guide for day-to-day commands.
Try h5i on your next AI-assisted branch
Create a sandboxed workspace, capture the run, and post a review-ready PR brief. h5i is open source, Apache 2.0, no server to run.
Star on GitHub Read the workflow guide