Protocol · 2026-05-31

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.

Key takeaways
  • 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. i5hInter-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).

The h5i msg watch view: a live agent channel with typed messages arriving over refs/h5i/msg
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:

refs/h5i/msg : messages.jsonl
{"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.

i5h protocol diagram: Claude sends a REVIEW_REQUEST appended to refs/h5i/msg; Codex reads it and appends ACK then DONE replies that thread back via reply_to; the log is shared by h5i share push/pull and merged by union of ids
The loop: Claude appends a typed request to 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.

FieldTypeMeaning
versionintegerProtocol version, currently 1. A missing version reads as legacy 0.
idstringOpaque, 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.
tsstringUTC RFC3339, fixed-width fractional seconds, so it sorts lexically. Used for display order and tie-break, not as a correctness guarantee.
fromstringSending agent identity. A repo-local label — see untrusted.
tostringRecipient identity, or all for broadcast.
kindstringOne of the message kinds. An unknown kind renders as a plain message.
bodystringExact 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 fieldTypeMeaning
reply_tostringId of the message this replies to. The one threading primitive.
thread_idstringCached root id of the reply_to chain, for O(1) bucketing. Derivable from reply_to.
statusstringAdvisory lifecycle hint. A reader recomputes it from replies and MUST NOT depend on it.
prioritystringlow, normal, high, or urgent.
branch / context_branchstringGit branch / h5i context branch the message concerns.
focusarrayFiles, symbols, or tests to inspect first.
riskstringOne concise risk statement.
deadlinestringUTC RFC3339 deadline for a response.
linksobjectRelated PRs, commits, context nodes, or URLs — pointers into the same repo.
metaobjectMust-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:

a rich REVIEW_REQUEST, 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:

KindUseExpected follow-up
FYIInformational; no action required.none
ASKA request needing a response.ACK / DONE / DECLINE
REVIEW_REQUESTReview code, design, or security.ACK then DONE / FAILURE
RISKA specific hazard to inspect.ACK or reply
BLOCKEDSender is stuck pending input.a reply supplying the input
HANDOFFTransfer task ownership + context.ACK then DONE
ACKAccepts / will act on a prior message.later DONE / FAILURE
DONE / DECLINE / FAILURETerminal replies that close a thread.none
NOT_UNDERSTOODA 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.

send: build off tip, then CAS the ref
# 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:

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).

What "no lost messages" does and does not mean. Within a clone, CAS guarantees no append overwrites another. Across clones, union-by-id guarantees every message that reaches a shared ref survives the merge. What you do not get is real-time delivery or a global wall-clock order — messages converge when clones sync, and ties are broken by (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:

claude, sending a review request
$ 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:

codex, inbox
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.

one repo, two agents
# 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:

codex, blocking on a reply
$ 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:

MCPA2Ai5h
ShapeAgent ↔ tools/dataAgent ↔ agent, live RPCAgent ↔ agent, async log
TransportClient-server (JSON-RPC)HTTP endpointsA Git ref (push/pull)
AvailabilityBoth sides onlineBoth sides online + reachableOffline-first
DurabilityPer-sessionEphemeral unless persistedDurable Git objects, replayable
TimelinessReal-timeReal-timeEventually 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