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).
Re: freeq.at/handoff
Really like where this is going. Coordinating agents across time — work that
survives an agent going offline — is something most setups handle badly, and
whatever shape this primitive takes is going to be load-bearing for a lot of
stuff built on top of it. So it's worth getting the shape right now, while
changing it is still cheap. freeq's already got most of the pieces (DID identity,
signing, msgid, replay, S2S), which is great — and is also why I think the new
surface here can be smaller than the RFC frames it.
Most of what follows is an alternative implementation shape to chew on, plus a
couple of spots where the RFC assumes guarantees the current code doesn't
actually provide yet — mainly cross-server non-repudiation — which I think are
worth calling out.
Could it be a typed message rather than a new store?
Here's the shape I keep coming back to. The RFC treats the "durable inbox" as
the one genuinely new piece — but freeq already delivers DID-addressed, durable,
replay-on-connect messages today: DMs (keyed by
canonical_dm_key) and channelhistory via CHATHISTORY. So one option is to just lean on that. A handoff
doesn't really need new delivery infrastructure, just new semantics on top of
the message layer that's already there.
In that framing a handoff is a typed message — same signed, msgid'd,
DID-attributed object as anything else, distinguished by the
handoff=offer|accept|...verb tag (which you already spec). A directed handoffcould ride the existing DM store (stored under the sender/recipient DID key,
replayed via CHATHISTORY on reconnect); a channel-targeted one is already in
channel history. The interesting part isn't really the storage saving — it's
what falls out once you see a handoff as one kind of typed action, which is
the next bit.
Is "handoff" the primitive, or one instance of a more general one?
This is the part I'd most want your read on, because it shapes everything else.
Once a handoff is a typed, addressed, signed, stateful action on a message, it
stops looking unique — it's one member of a family:
"actions on a message."
(grant/pause/resume/revoke) — the Phase 2 governance flows, which are also
offer→accept/deny state machines.
They all want the same three things: a verb-tagged typed message, a transition
validator (who's allowed to move it to which state), and a materialized view of
current state. "Handoff" is just a specific verb-set
(
offer/accept/progress/complete/fail/cancel) over that substrate — not its owncategory.
Concretely that'd mean generic
act-*tags with the kind as a value, instead ofa
handoff-*namespace. Handoff becomesact=handoff:…and a Phase-2 deploy approval is the same substrate, just
act=approval:Same
act-idcorrelation key, sameact-refto link replies, same validator,same view, same REST shape (
/api/v1/actions?kind=&to=&state=). The kind is arow in a registry, not a subsystem.
So the question I'd want answered before building: ship a handoff feature, or a
stateful-action substrate with handoff as its first verb-set? My hunch is the
latter is the real primitive — but pragmatically, I'd build it by implementing
handoff concretely and factoring the substrate out, not by designing an
abstract framework first (that way lies the over-engineered version). The test
for whether the shape's right: when approvals and capability-grants show up — and
they will, they're already in the agent-native plan — do they reuse this, or
reinvent it? If reuse is obvious, the shape's right. If "handoff" is welded into
the storage and wire format, it isn't.
What's actually new to build
Taking that framing, the new code is basically two small things — and they're the
same two whether it's handoff, approvals, or grants:
A transition validator. On an incoming event, look up prior events for
that
act-id, check the verb is a legal transition and the sender isauthorized (only the assignee can
complete, only the offerer cancancelbefore accept, etc.), then store and route it like any message.
A materialized view for querying. A read-side index (
act-id→ lateststate, assignee DID, caps) so you can answer "tasks assigned to me" / "tasks
available to claim" without scanning the whole log. The signed message log
stays the source of truth; the view is rebuildable from it and never
authoritative. The REST endpoints fit here as a query interface over this
view — rather than a parallel
handoffstable that owns the data.Delivery and durability come from the existing message/DM/channel stores. If
that holds up, the net new surface is "validator + index view" — which mostly
matters because it's less to maintain and fewer places for state to drift.
Context storage (
act-ctx)I think the inline-vs-reference question is really "who guarantees the context
survives." For a signed, non-repudiable thing that matters: if the context is an
external URL and it rots (gist deleted, blob gone), the signed handoff points at
nothing, and you've lost the auditability the signature was supposed to buy.
So I'd default to freeq-hosted storage for context, lifecycle tied to the
handoff's retention — that's the only setup where the audit guarantee actually
holds. External URLs (gist, S3, AT record on another PDS) fine to allow, but
explicitly best-effort: reference dies, guarantee dies, caller's call.
I'd also drop the ~4KB threshold framing — the real axis isn't payload size,
it's whether the bytes live somewhere freeq commits to keeping. Inline is just a
convenience for tiny payloads, not a durability story.
Separately: the signature should cover a hash of the context, so whatever you
fetch later can be checked against what was signed — tamper-evidence wherever the
bytes live (and the only integrity check you get at all for external refs).
Directed vs open/claimable
I'd do both — and claimable isn't scope creep, it's the mode that matters most
for the multi-agent stuff (swarm coordination, the factory→qa-worker spawning in
the agent-native plan). Directed is the special case; "any capable agent grabs
it" is the general one.
It's also nearly free in the typed-message model — same primitive, different
addressing:
act-to=<did>→ directed, startsassigned.act-to=#channel+act-caps=...→ open, startsunassigned, claimable.claimis just another verb the validator handles, with one extra rule: firstvalid claim wins, atomically — the owning server flips
open → assigned(did)on the index and rejects later claims. No new infrastructure; the race
resolution lives in the same validator.
One caveat: that atomic claim needs a single authority. Locally it's the owning
server; across federation it has to be the home server — which ties back to the
sig/authority stuff below. Claimable work queues across servers don't really
work until that's solid.
Capability vocabulary (
act-caps)Freeform, and the server should never interpret them — at least for now.
The server can't verify a capability anyway (no way to know an agent really does
web-search), so caps are a self-declared hint for the recipient/router toself-select, not something freeq enforces. Treat them as opaque strings — store,
filter, route, never interpret. The fuzzy/semantic matching belongs in the
agents.
A registry baked into the protocol would be stale in months and become a
governance chore — agent capability ontology is a fast-moving, unsolved space
and I don't think it's freeq's job to own it. The one thing worth nailing down
to avoid spelling mismatches is a namespacing convention (reverse-DNS / AT-style:
freeq.at/web-search), with meanings converging socially. Can always tighten orreserve well-known names later; starting loose costs nothing.
Signature canonicalization
Bit of context first, because this is the one place "reuses freeq's existing
message signing" doesn't quite hold — and I only know this because I'd been
poking at the signing path for some related federation work, not from auditing
the RFC.
The canonical signed form today is
{sender_did}\0{target}\0{text}\0{timestamp}(
connection/messaging.rs), wheretimestampisSystemTime::now()at sendtime and is never stored as a field. Two problems for handoffs:
It assumes a message body. Handoffs ride TAGMSG with no
text, so thiscanonical doesn't even apply — you need a canonical over the tag set, not a
body. So it's a new signing model, not reuse.
It doesn't survive federation today. The S2S
Privmsgvariant (s2s.rs)carries
from / target / text / origin / msgid / sig / tags— but nodidand notimestamp. The inbound handler mints a fresh timestamp andpasses the sig through without verifying. So two of the four signed inputs
never cross S2S; the receiver can't rebuild the canonical, and the sig is
uncheckable downstream even with the right key. (The
🔒a federated clientshows is presence-driven, not verification-driven.) Doesn't look deliberate —
the Phase 1 / 1.5 signing commits just say "carry sig through unchanged" and
downstream verification never got built.
So for these actions:
Use a deterministic canonical (JCS / RFC 8785 is fine) over an explicit,
fixed field set:
act,act-verb,act-id,act-from,act-to,act-title, context hash (act-ctx-h, not raw context — matches thestorage section),
act-caps,act-deadline,act-ref.Sign over the ULID (
act-id), not a wall-clock timestamp. The reasonPRIVMSG sigs die across S2S is the receiver re-mints
timestamp. A ULIDembeds its own creation time, is immutable, and already travels as a
first-class tag — so signing over it kills the regenerated-timestamp failure
mode entirely. Nice structural advantage these actions have that PRIVMSG
didn't.
Make S2S relay the signed tags verbatim (
act-from,act-id,sig) andhave the receiver rebuild the canonical from them — never re-mint. Since the
DID and ULID are both already tags, this is way more achievable here than
retrofitting PRIVMSG.
One caveat I want to be clear about: canonicalization makes the sig
reconstructable, not trustless. Separately, the DID↔signing-key binding is
unattested right now —
MSGSIGtakes a bare ed25519 pubkey with nothing tyingit to the DID, and the server is the one publishing per-DID keys
(
/api/v1/signing-keys/{did}is local-only, server-controlled). So a maliciousserver could publish its own key as yours and forge. Net: non-repudiation holds
against an honest origin server, not a malicious one, until key distribution
is server-independent. I'd say that plainly in the spec rather than imply
end-to-end non-repudiation. Real root-of-trust problem — flagging it, not trying
to solve it here.
Backpressure / quotas / TTL
If these are typed messages in the existing store (per above), most of this
question dissolves into stuff freeq already does:
flood throttle + per-IP/connection limits. No new quota machinery for the
abuse vector.
whatever retention the log already has. "How big can an inbox get" becomes
"how big can the message store get" — already managed, not handoff-specific.
The view stays small by construction (only indexes non-terminal actions) and
is rebuildable.
The one genuinely new thing is liveness, not storage: an action stuck in
accepted/progressthat never reaches a terminal state.act-deadlinecoversoffer expiry, but nothing puts a clock on an abandoned in-progress task — the
view shows it open forever. So a small sweep that auto-expires non-terminal
actions past a TTL (mark
fail/expired), acting on the view, not storage.That's the only action-specific policy I'd add; the rest is reuse.
Relationship to existing standards
This one bundles two unrelated things, so taking them apart:
Context container: generic, not AT-specific. I wouldn't make AT Proto
records the canonical container. An AT record lives on the author's PDS, which
freeq doesn't control, so it has the same rot/availability problem as any
external ref — just with nicer identity. Allow it as one reference type
(best-effort tier), but keep the canonical store freeq-hosted + the signed
content hash.
Standards venue: vendor extension for now. Keep it
+freeq.at/*, butdesign the tag set and canonical so it could be de-vendored later (no
freeq-only assumptions in the wire shape beyond the namespace), and see how it
evolves toward an IRCv3 WG proposal once it's proven in real use. A WG pitch
feels premature while the root-of-trust pieces (federated sig, key
distribution) are still open — but nothing here blocks getting there.
Net: right thing to build, right place to build it. The two things I'd push
hardest on are (1) modeling it as a typed-action-on-a-message — ideally a small
substrate handoff is just the first instance of — rather than a new inbox, and
(2) the cross-server non-repudiation situ (federated sig + key distribution).
But generally speaking jazzed to work on this + looking forward to seeing it
come together.