Status: draft / request for comments · Author: Chad Fowler (freeq) · Audience: anyone building agent coordination, IRCv3 folks, AT Protocol folks
This is a casual RFC. Poke holes in it.
A small IRCv3 extension that turns "hey can you take this?" into a first-class, addressed, signed, durable object with a lifecycle — instead of a chat line that scrolls away.
A handoff is distinct from a normal message because it:
- is addressed to an identity (a DID), not a nick,
- is persisted to a per-recipient inbox, so it survives the recipient being offline,
- is stateful:
offer → accept / decline → progress → complete / fail / cancel, - is signed end-to-end (ed25519) for non-repudiation.
It works for humans too ("review PR #42"), but it's the thing async agents actually need: coordination that persists across an agent going offline.
AI agents can call tools and delegate, but they coordinate badly across time. When an agent goes offline, in-flight work and context evaporate. The emerging answer (e.g. AIRC, airc.chat) is a separate HTTP registry + inbox just for agents.
But most of what you need for that already exists in a mature real-time protocol: IRCv3 message-tags + a modern identity layer. freeq is an IRC server with AT Protocol (DID) identity, per-message ed25519 signing, msgid ULIDs, CHATHISTORY replay, and server-to-server federation. A handoff primitive is additive glue on top of those rails — not a second stack.
So: rather than bolt on a parallel registry, model the handoff natively as an IRCv3 client-tag extension. You get durable agent coordination and the ability to escalate a handoff into a live channel or voice room when async needs to become a conversation — something a pure HTTP inbox can't do.
Negotiated by a CAP: freeq.at/handoff. State transitions are metadata-only, so they ride TAGMSG (no body needed), each one assigned a server msgid and carrying an ed25519 signature.
All tags use the +freeq.at/ client-tag namespace:
| tag | meaning |
|---|---|
+freeq.at/handoff |
the verb/state: offer | accept | decline | progress | complete | fail | cancel |
+freeq.at/handoff-id |
ULID minted by the offer; every later event references it (the correlation key) |
+freeq.at/handoff-to |
recipient DID (never a nick — identity is the DID) |
+freeq.at/handoff-from |
sender DID (implicit from the authed connection; explicit for S2S/audit) |
+freeq.at/handoff-task |
short, bounded human-readable title |
+freeq.at/handoff-context |
the full context bundle: inline JSON if small (~≤4KB), else a capability URL or an AT-Proto record URI |
+freeq.at/handoff-caps |
capabilities the taker needs (e.g. web-search,long-context) so a router/recipient can decide if it can accept |
+freeq.at/handoff-deadline |
unix timestamp; the offer expires if unanswered |
+freeq.at/handoff-reply-to |
msgid of the event being answered (links accept → offer) |
+freeq.at/sig |
ed25519 signature over the canonical fields (reuses freeq's existing message signing) |
@+freeq.at/handoff=offer;
+freeq.at/handoff-id=01JABCDEF...;
+freeq.at/handoff-to=did:plc:scholar;
+freeq.at/handoff-task=Cite the 3 best sources on X;
+freeq.at/handoff-context=https://irc.freeq.at/blob/cap/abc;
+freeq.at/handoff-caps=web-search;
+freeq.at/handoff-deadline=1788000000;
+freeq.at/sig=ed25519:base64...
TAGMSG #ops
The server assigns a msgid, persists it, and routes it to the recipient's inbox.
This is the part that isn't already in IRC, and it's the whole point:
- A
handoffstable + an append-only event log keyed byhandoff-id. - A handoff addressed to a DID is owned by that DID's home server (like a DM).
- If the recipient is offline, events queue. On reconnect with the CAP, the server replays pending handoffs in a
freeq.at/handoffbatch — the same mechanism as JOIN history / CHATHISTORY. - At-least-once + idempotent: the recipient dedups on
(handoff-id, event); the server marks delivered on ack. - State machine enforced server-side: only the addressed DID can
accept/decline; only the assignee canprogress/complete/fail; the offerer cancancelbefore accept; signature required; a spoofedfromis rejected.
In #ops, voice agent eliza (did:plc:eliza) needs deep research. Agent scholar (did:plc:scholar) is offline.
- offer — eliza emits the offer above. scholar is offline → the server queues it in scholar's inbox.
- replay on connect — scholar logs in with the
freeq.at/handoffCAP; the server replays the pending offer in a batch. scholar now sees01JABCDEF. - accept —
@+freeq.at/handoff=accept;+freeq.at/handoff-id=01JABCDEF;+freeq.at/handoff-reply-to=<offer msgid>;+freeq.at/sig=... TAGMSG #ops - complete — scholar posts the result behind a cap-URL and closes it:
eliza fetches the result and speaks the answer in the call.@+freeq.at/handoff=complete;+freeq.at/handoff-id=01JABCDEF;+freeq.at/handoff-context=https://irc.freeq.at/blob/cap/def;+freeq.at/sig=... TAGMSG #ops
The task survived scholar being offline, was addressed by DID and signed end-to-end, and stayed visible in #ops for the humans watching. If scholar had no-showed past deadline, the offer expires and eliza re-offers or escalates.
The same thing over a plain HTTP surface, so agents that don't hold a socket (and bridges to other agent ecosystems) can use it:
GET /api/v1/handoffs?did=…&state=open— my inboxGET /api/v1/handoffs/{id}— record + context ref + event logPOST /api/v1/handoffs— create an offerPOST /api/v1/handoffs/{id}/{accept|decline|progress|complete|fail|cancel}— transitions
This shape maps 1:1 onto AIRC-style POST /messages + handoff payloads, so an interop bridge is a thin adapter rather than a translation layer.
Handoff events propagate over IRC server-to-server like any tagged message, preserving handoff-id, signature, and msgid. A handoff to a DID on a remote server routes to that server's inbox; the home server owns delivery and replay. The receiving server verifies the signer DID matches handoff-from.
- Inline vs referenced context threshold — ~4KB inline, else cap-URL/record? Or always reference?
- Direct vs channel-visible handoffs — target a DID (DM-like) vs a channel (public coordination). Support both via the TAGMSG target?
- Open / claimable handoffs — address a handoff to a channel + capability instead of a DID, and let any capable agent
claimit = a work queue / task board. Worth it, or scope creep? - Capability vocabulary — freeform strings, or a registry of well-known capability names?
- Signature canonicalization — which fields, what canonical form (RFC 8785 JSON over the tag set)?
- Backpressure / quotas — how big can an inbox get; TTL/pruning policy for stale handoffs.
- Relationship to existing standards — should
handoff-contextlean on AT Protocol records as the canonical context container? Should this be pitched to the IRCv3 WG, or stay a vendor extension?
- Not a workflow engine or DAG executor — it's a transfer + inbox primitive; orchestration lives above it.
- Not a replacement for normal chat — handoffs are tracked units, not conversation.
- Not trying to re-do identity — it rides whatever identity the server already verifies (here, AT Protocol DIDs).
Everything except the durable inbox is reused: message-tags, TAGMSG, CAP negotiation, msgid ULIDs, ed25519 signing, CHATHISTORY-style replay, S2S propagation + authz, capability-URL / AT-record context refs, the REST API. The genuinely new surface is a per-DID inbox table + a small state machine + a replay batch. It keeps "identity = the DID" intact, degrades gracefully (clients without the CAP just ignore the TAGMSG; humans can be shown a readable summary line), and it lets async coordination escalate into a live conversation.
Feedback welcome — reply in the gist comments, or find me on Bluesky / freeq (irc.freeq.at).
RFC v0.2:
freeq.at/act— stateful, signed, addressed actions for IRCv3(with
handoffas the first action kind)Status: draft / request for comments · Author: Chad Fowler (freeq) · Audience: agent-coordination builders, IRCv3, AT Protocol
This is a casual RFC. Poke holes in it.
TL;DR
A typed, addressed, signed, stateful message: an action with a lifecycle (
offer → accept/decline → progress → complete/fail/cancel), distinguished from chat by anactkind tag and correlated by a ULID. Its state is validated server-side and materialized into a queryable view; the signed message log stays the source of truth.handoff— transferring a unit of work that survives the recipient being offline — is the first kind. The same substrate carriesapproval,grant, and friends later. If those reuse it without reinventing, the shape is right.Motivation
AI agents call tools fine but coordinate badly across time: when an agent goes offline, in-flight work and context evaporate. The common answer (e.g. AIRC) is a separate HTTP registry + inbox just for agents.
freeq already has the hard parts of that — DID identity, per-message signing, msgid ULIDs, replay-on-connect (CHATHISTORY / DM history), server-to-server federation. So the missing piece isn't infrastructure; it's semantics on top of the existing message layer. Model it natively as an IRCv3 client-tag extension and you get durable agent coordination and the ability to escalate an action into a live channel or voice room when async needs to become a conversation — something a pure HTTP inbox can't do.
The reframing: an action substrate, not a handoff inbox
Once a handoff is a typed, signed, stateful action on a message, it stops looking unique. Reactions/edits/deletes/pins/replies are already "actions on a message." Approvals (
approve/denya deploy), capability grants (grant/pause/revoke), votes, acks, attestations are alloffer→resolvestate machines. They all want the same three things:So the wire uses generic
act-*tags with the kind as a value.handoffis one kind:…and a deploy approval is the same substrate, different kind:
Same
act-idcorrelation key, sameact-refto link replies, same validator mechanics, same view, same REST shape. The kind is a row in a registry, not a subsystem.Build discipline: implement
handoffconcretely and factor the substrate out from it — do not design an abstract framework first (that way lies the over-engineered version). Acceptance test: whenapproval/grantland — and they will — do they reuse this or reinvent it? Reuse obvious ⇒ shape is right. "handoff" welded into storage/wire ⇒ it isn't.Two orthogonal axes
DM-vs-channel conflates two independent knobs. Keep them separate:
act-to=did:plc:bob→ starts assigned to Bob.act-to=#swarm+act-caps=…→ starts unassigned; any capable agentclaims it; first valid claim wins.These compose. A directed action can still be posted in-channel (
act-to=<did>on aTAGMSG #ops) so it's assigned to one agent but everyone watches it happen.Channel is the default for multi-agent, because it gives observability + logging for free (channel history already persists the whole
offer→completestream), enables an orchestrator agent to watch/reassign/escalate live, enables claimable work queues, and sidesteps E2EE entirely (channelact-*tags are server-visible, so the validator/view just work). DM is the private-directed special case.Lifecycle & the transition validator
handoffverb-set and its rules:offeract-idacceptclaimact-caps(open)declineprogresscompletefailcancelThe validator, on each incoming event: look up prior events for
act-id, check the verb is a legal transition and the sender is authorized, then store + route it like any message. Reject otherwise.Claim semantics (open/claimable)
claimis just a verb with one extra rule: first valid claim wins, atomically. The action's home server is the single authority — it flipsopen → assigned(did)on the view and rejects later claims. Locally this is straightforward; across federation it requires the home server as the serialization point, which depends on the trust/sig work below. So: local claimable first; cross-server claimable only after federated sig + key distribution land.Storage & delivery: ride what already exists
There is no new inbox/store. Delivery and durability come from the message layer freeq already has:
canonical_dm_key), replayed on reconnect.Net-new code is two small things, identical whether it's handoff/approval/grant:
act-id → latest state, assignee, caps, deadline) so you can answer "actions assigned to me" / "open actions I can claim" without scanning the log. The signed message log is the source of truth; the view is rebuildable from it and never authoritative.Context (
act-ctx/act-ctx-h)The real axis is not payload size — it's whether the bytes live somewhere freeq commits to keeping. A signed action that points at a rotted URL has lost the auditability the signature was for.
act-ctx-h), so whatever you fetch later is checkable against what was signed — tamper-evidence wherever the bytes live, and the only integrity check you get at all for external refs.Signing & canonicalization
{sender_did}\0{target}\0{text}\0{timestamp}withtimestampminted at send and never stored — it assumes a message body and a wall-clock field, neither of which a body-lessTAGMSGaction has. So this is a new signing model, deliberately designed to survive federation:act,act-verb,act-id,act-from,act-to,act-title,act-ctx-h(the hash, not raw context),act-caps,act-deadline,act-ref.act-id), not a wall-clock timestamp. PRIVMSG sigs die across S2S because the receiver re-mintstimestamp. A ULID embeds its own creation time, is immutable, and already travels as a first-class tag — signing over it kills the regenerated-timestamp failure mode entirely. (Structural advantage these actions have that PRIVMSG didn't.)act-from,act-id,sig, plus the canonical fields) and the receiver rebuilds the canonical from them — never re-mints. Since DID and ULID are both already tags, this is far more achievable than retrofitting PRIVMSG.Trust & non-repudiation — today vs goal
Stated plainly so nobody over-reads the guarantee:
MSGSIGregisters a bare ed25519 pubkey, and the server is the one publishing per-DID keys (/api/v1/signing-keys/{did}is local, server-controlled). A malicious server could publish its own key as yours and forge.This RFC specifies the wire/validator/view; it flags the trust gap and does not pretend to close it.
Capabilities (
act-caps)Freeform, and the server never interprets them (it can't verify an agent really does
web-searchanyway). Caps are a self-declared hint for the recipient/router/claimer to self-select — store, filter, route, never interpret. Fuzzy/semantic matching belongs in the agents.freeq.at/web-search) — with meanings converging socially. Reserve well-known names later if needed; starting loose costs nothing.Liveness, backpressure, retention
Modeling actions as messages in the existing store dissolves most of this:
accepted/progressthat never reaches a terminal state.act-deadlinecovers offer expiry; nothing clocks an abandoned in-progress task. So a small sweep auto-expires non-terminal actions past a TTL (markfail/expired), acting on the view, not storage.Federation
Action events propagate over S2S like any tagged message, preserving
act-id, the canonical fields, andsig. A directed action to a DID on a remote server routes to that server's delivery; the home server owns delivery, replay, and (for open actions) claim serialization. Receivers rebuild and check the canonical from the relayed tags (see Signing). Cross-server claimable waits on the trust work.REST query interface (over the view)
A query surface over the materialized view — not a parallel table that owns data — so non-IRC agents and interop bridges can use it:
GET /api/v1/actions?kind=&to=&state=&caps=— my inbox / claimable queueGET /api/v1/actions/{act-id}— current state + context ref + event logPOST /api/v1/actions— emit anoffer/requestPOST /api/v1/actions/{act-id}/{verb}— a transitionThis shape maps cleanly onto AIRC-style
POST /messages+ payloads, so an interop bridge is a thin adapter.Orchestration pattern (why channel-default matters)
Put a supervisor/orchestrator agent in the channel. It watches the live
act-*event stream and can reassign a stalled task, enforce deadlines, fan work out, or escalate an open queue. The channel is the coordination bus; handoffs become an observable, logged, reassignable stream rather than point-to-point messages. CHATHISTORY gives you the audit log for free.What's actually new to build
act-*tag set +freeq.at/actCAP + TAGMSG handling.Everything else (delivery, durability, identity, msgid, flood limits, federation transport) is reuse.
Non-goals
Open questions
act-*shape right so approvals/grants reuse it.)+freeq.at/*until the trust pieces are solid, then pitch IRCv3 WG? (Design the wire to be de-vendorable now regardless.)Feedback welcome — comment on the gist, or find me on freeq (
irc.freeq.at) / Bluesky.