Skip to content

Instantly share code, notes, and snippets.

@chad
Created June 23, 2026 21:32
Show Gist options
  • Select an option

  • Save chad/8cfe1097856d2cfd048639f2e9c74209 to your computer and use it in GitHub Desktop.

Select an option

Save chad/8cfe1097856d2cfd048639f2e9c74209 to your computer and use it in GitHub Desktop.
RFC (draft): freeq.at/handoff — a durable, signed task-handoff primitive for IRCv3

RFC: freeq.at/handoff — a durable, signed task-handoff primitive for IRCv3

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.


TL;DR

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:

  1. is addressed to an identity (a DID), not a nick,
  2. is persisted to a per-recipient inbox, so it survives the recipient being offline,
  3. is stateful: offer → accept / decline → progress → complete / fail / cancel,
  4. 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.

Motivation

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.

The primitive

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)

Wire example (the offer)

@+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.

The one genuinely new piece: the durable inbox

This is the part that isn't already in IRC, and it's the whole point:

  • A handoffs table + an append-only event log keyed by handoff-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/handoff batch — 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 can progress/complete/fail; the offerer can cancel before accept; signature required; a spoofed from is rejected.

Lifecycle walkthrough

In #ops, voice agent eliza (did:plc:eliza) needs deep research. Agent scholar (did:plc:scholar) is offline.

  1. offer — eliza emits the offer above. scholar is offline → the server queues it in scholar's inbox.
  2. replay on connect — scholar logs in with the freeq.at/handoff CAP; the server replays the pending offer in a batch. scholar now sees 01JABCDEF.
  3. accept —
    @+freeq.at/handoff=accept;+freeq.at/handoff-id=01JABCDEF;+freeq.at/handoff-reply-to=<offer msgid>;+freeq.at/sig=... TAGMSG #ops
    
  4. complete — scholar posts the result behind a cap-URL and closes it:
    @+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
    
    eliza fetches the result and speaks the answer in the call.

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.

REST mirror (for non-IRC clients + interop)

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 inbox
  • GET /api/v1/handoffs/{id} — record + context ref + event log
  • POST /api/v1/handoffs — create an offer
  • POST /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.

Federation

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.

Open questions (please weigh in)

  • 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 claim it = 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-context lean on AT Protocol records as the canonical context container? Should this be pitched to the IRCv3 WG, or stay a vendor extension?

Non-goals

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

Why this shape

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

@zapnap

zapnap commented Jul 1, 2026

Copy link
Copy Markdown

v0.2 looks great. Thanks for taking all that feedback into consideration! Two small points and two bigger ones that will probably need resolution:

1. Drop "private to two DIDs" claim The server has to read act-* tags to validate transitions, so in every mode it sees the participants, title, deadline, and full state timeline — and usually the context too, if freeq-hosted. E2EE only ever covers the freeform body. So DM actions are direct, not private; I'd say that as plainly as the trust section says its thing. One nuance worth keeping: the validator only needs id/verb/DIDs/deadline — never the title or context — so an encrypted-content mode is possible later with no wire change. Existence, participants, and timeline are the part that can never be hidden. (Also, DM prekeys are served by the server, so E2EE DMs have the same honest-server trust bound as MSGSIG — same root-of-trust gap, one more consumer.)

2. "Home server" doesn't exist for channels. Directed/DM actions have one; a claimable action in a federated channel doesn't — channel state is symmetric peer-merge, so two peers can each award the claim locally and the task runs twice. Proposal: the server that minted the act-id owns claim serialization for that action. Deterministic from the wire, no new channel-ownership concept, and if that server is unreachable claims stall rather than fork — the right failure for first-wins.

3. Sig verification is net-new — and per-session keys are the real problem. Nothing today verifies a relayed sig; S2S passes sig through unchecked. The check itself belongs inside the validator (first rule it evaluates, bad sig = event rejected), and that part is straightforward. The hard part is the key: clients mint a fresh ed25519 keypair every session, and signing_keys overwrites on re-register — so the moment a signer reconnects, every sig from prior sessions is unverifiable. Chat survives that; handoffs are specifically long-lived, so the normal case is "verify an offer signed in a session that has since ended," which the current key model can't do at all. Verification needs two things: the exact bytes that were signed, and the key to check them against. Your sign-over-ULID change fixed the first — the signed fields all travel as tags, so anyone can rebuild the bytes later. But nothing fixed the second: the key that could check them is gone after one reconnect. And fixing it has two parts, because a verifier needs to answer both "does the key still exist?" and "who do I ask for it?". A key-id in the sig tag + an append-only key store (instead of overwrite) answers the first. For the second, the interim answer is the event's origin server — session keys are registered where the session lived, and every relayed event already names its origin, so the lookup is (origin, DID, key-id). No new "home server" concept needed. But be clear what that costs: verification is then hostage to the origin being reachable (server dies → its history is unverifiable forever) and honest (the same honest-origin trust bound you already call out). Which is why the DID-doc anchoring you name as the goal isn't just the trust upgrade — it's the real answer to "who do I ask": a DID document resolves from the DID alone through AT Proto's existing infrastructure, no freeq server in the loop. I'd put the key-id in the sig format now so the wire doesn't need revising as the key authority moves.

4. Directed delivery has to be DID-addressed all the way down — and today the DM layer isn't. The RFC addresses handoffs to DIDs, which is right. But if directed acts ride the DM store, look at what that layer actually does across federation: PRIVMSG targets are nicks only, a DM to a non-local nick is broadcast to all peers (routing.rs), and each receiving server independently decides who that nick is from its own records. No step anywhere agrees on the recipient's identity — under a nick collision two servers can deliver to two different people, and the sender's server never learns which DID (if any) received it, which is also why it can't persist its own copy of the conversation. A cross-server nick DM has no well-defined recipient, full stop. Acts must not inherit that: delivery, persistence, and validation should key off the act-to DID end-to-end, with the nick target as display only. Implementation-wise that's two pieces:

  • DID-addressed traffic is the easy case — a DID is globally unique, so every server applies the same rule: deliver to local sessions bound to that DID (did_sessions already exists) and relay to peers, who do the same — so a multi-homed DID gets it on every device, deduped by msgid. No interpretation, no collision. Allowing DID targets at the wire level (PRIVMSG did:plc:x / TAGMSG likewise — CHATHISTORY already accepts did: targets) gives agents this path directly and they never touch nicks at all.
  • Nick-addressed DMs: resolve once at the sender, carry the DID on the wire — the sender's server resolves nick→DID at send time and stamps the resolved recipient DID on the S2S payload; receivers deliver to that DID instead of re-interpreting the nick. One resolution, authoritative, carried on the wire — delivery and the sender's own persisted copy can't diverge, because they're the same answer. The stamp is best-effort: when it's absent (legacy peer, or the nick doesn't resolve — e.g. a guest), receivers fall back to today's nick interpretation. That's fine — a guest has no durable identity to persist under anyway, and act participants are DIDs by definition, so acts never hit this case.

Either way the addressing model should be decided in the spec, not discovered in the implementation.

1 is just wording; 2–4 are real design decisions worth settling before code. Nothing here breaks the core shape though. LFG

@zapnap

zapnap commented Jul 2, 2026

Copy link
Copy Markdown

Took a pass at a v0.3 folding in the thread above — pushed it as a fork so it shows a clean diff off your v0.2: https://gist.github.com/zapnap/4cadada009a23cb11203b66d23278447

Most of it firms up the four things we'd been circling: DID-native addressing, claim authority on the act-id-minting server, the signing-key durability/lookup gap, and retiring "private" for direct actions.

One thing that wasn't in my earlier comment and is worth your eye: it argues the act signing model should be freeq's single signing model — today's PRIVMSG signing breaks across federation the same way — and proposes migrating all message signing onto this canonical as follow-on work (out of scope for the RFC itself, but flagged so it doesn't leave two signing paths forever).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment