The whole protocol is one signed envelope.

An instance talks to another instance by POSTing a JSON envelope to /api/federation/inbox over HTTPS. No broker, no message bus, no account with us. A team’s coordinator is just a member whose instance fans out team-wide events, and that role moves with a signed record.

Not an engineer? The platform page is the plain-language version: what the agents do, what it costs.

Nine fields. Eight are signed; the ninth is the signature.

Hover or tap a field to read what it does. This is a handoff: Alice’s Closer agent asking Bob’s Voice agent to rewrite a draft, sent from Alice’s instance to Bob’s.

POST /api/federation/inbox
hover or tap a field
{
  "kind": "agent.handoff.requested",  "teamId": "2f1c9c1e-6a5d-4a0e-9e2b-7c4b7d3f1a90",  "fromHumanId": "alice@viox.ai",  "fromPublicKey": "3f4bf05ec51a5f66911df69ff91d17c63ce1235583333e1a10310b39833a90a5",  "fromInstanceUrl": "https://os.alice.example",  "payload": {
    "originHandoffId": "8c6b2e51-3d0f-4b7a-9f1e-5a2c6d7e8f90",
    "fromAgent": "closer",
    "toAgent": "voice",
    "summary": "Rewrite the Acme landing-page draft in their brand voice",
    "context": {
      "draftUrl": "vault://acme/landing-v3.md",
      "dueBy": "2026-09-09"
    }
  },  "nonce": "6e0b0c3a-1f7b-4d1e-9a5c-2b9f0d8e7c41",  "sentAt": "2026-09-07T14:02:11.418Z",  "signature": "593ae86310fa70ee917424aac17ff16420b44ece0a14562b95903cc83f1f75fa05a19c42434ddd9a5be99610376b9d6812b60ac6b414556ac4cbb3c79547e40b"Ed25519 over the canonical JSON of the eight fields above, 64 bytes, hex. Verified against fromPublicKey before any handler runs.}
This envelope verifies. The signature was produced by the gateway’s own signing path with a throwaway key made for this page.

Canonical JSON, signed with Ed25519.

Both sides serialize the eight signed fields with keys sorted recursively, so a Mac, a Linux VPS, and a Windows laptop produce the same bytes. The signature covers those bytes.

Keys are plain Ed25519 from node:crypto. No native module, no third-party crypto dependency. The private key is the 32-byte seed, the public key the 32-byte point, both stored as hex. Signatures are 64 bytes, also hex.

A relay never re-signs. When a coordinator fans an envelope out to the rest of a team, peers verify the original sender’s signature, so the coordinator cannot alter what it forwards.

// packages/gateway/src/federation/outbox.ts
function envelopeSignablePayload(
  e: Omit<OutboundEnvelope, 'signature'>,
): string {
  return canonicalJson({
    kind: e.kind,
    teamId: e.teamId,
    fromHumanId: e.fromHumanId,
    fromPublicKey: e.fromPublicKey,
    fromInstanceUrl: e.fromInstanceUrl,
    payload: e.payload,
    nonce: e.nonce,
    sentAt: e.sentAt,
  });
}
What gets signed: exactly these eight fields, in canonical order.
// packages/gateway/src/federation/identity.ts
export function canonicalJson(value: unknown): string {
  if (value === null || typeof value !== 'object') {
    return JSON.stringify(value);
  }
  if (Array.isArray(value)) {
    return '[' + value.map(canonicalJson).join(',') + ']';
  }
  const keys = Object.keys(value as Record<string, unknown>).sort();
  return (
    '{' +
    keys
      .map(
        (k) =>
          JSON.stringify(k) +
          ':' +
          canonicalJson((value as Record<string, unknown>)[k]),
      )
      .join(',') +
    '}'
  );
}
Canonicalization: recursive key sort, no whitespace.
// packages/gateway/src/federation/identity.ts
export function sign(identity: Identity, payload: string): string {
  const key = privateKeyFromHex(identity.privateKey);
  const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), key);
  return sig.toString('hex');
}

export function verify(
  publicKeyHex: string,
  payload: string,
  signatureHex: string,
): boolean {
  try {
    const key = publicKeyFromHex(publicKeyHex);
    return crypto.verify(
      null,
      Buffer.from(payload, 'utf-8'),
      key,
      Buffer.from(signatureHex, 'hex'),
    );
  } catch {
    return false;
  }
}
Sign and verify: verification failures return false, never throw.

Eight checks, in the order they run.

This is the receive path in api/federation.ts, in the order it runs. The first failing check answers; nothing later executes.

  1. 1
    Shape400 malformed envelope

    kind, fromHumanId, fromPublicKey and signature must all be strings. Nothing else is read until they are.

  2. 2
    Freshness400 stale

    sentAt must parse and sit within ±60 minutes of the receiver’s clock. Nonces are pruned after 24 hours; the window is kept far inside that so a captured envelope cannot outlive its nonce.

  3. 3
    Replay409 duplicate_nonce

    The nonce is looked up in federation_nonces. Seen before: refused. Not seen: remembered once the envelope has verified and been applied, so a failed attempt never poisons a legitimate retry.

  4. 4
    Rate429 rate_limited

    A per-signing-key, per-kind, per-minute counter. Caps differ by kind (table below). The ceiling is read here, before any cryptography, so a flood of junk never reaches the Ed25519 path; the counter itself only increments once the signature has verified.

  5. 5
    Signature401 signature_invalid

    The eight fields are re-canonicalized and verified against fromPublicKey. Every attempt, valid or not, is written to federation_inbox with verified = 0 or 1 so you can audit who knocked.

  6. 6
    Membershiplogged, not applied

    Each kind’s handler looks the sender up by public key inside teamId. A key that is not an active member gets its envelope recorded with a rejection note and nothing else happens.

  7. 7
    Apply200 ok

    A handoff becomes a pending work item for the target agent. A plan event merges by hybrid logical clock. A presence ping updates the roster. An approval decision unblocks the waiting agent.

  8. 8
    Relaycoordinator only

    If this instance coordinates the team and the kind is broadcastable, the envelope is forwarded byte-for-byte to every other active member.

What can be inside, and how often.

Twelve kinds are rate-limited; eleven have a receive handler. Caps are per signing key, per kind, per minute: checked before signature verification, counted only after it. Unlisted kinds default to 60 and are stored without a handler.

kindcarriescap / minrelayed
agent.message
A direct message from one operator’s agent to another’s inbox
30no
agent.handoff.requested
An @mention turned into a tracked work item on the target instance
60no
agent.handoff.resolved
Completion or rejection, sent back to the origin instance
60no
approval.requested
An agent needs a named human’s sign-off before it proceeds
10no
approval.decided
The decision, mirrored back to whoever asked
50no
plan.event
A Kanban or plan change, ordered by hybrid logical clock
200by coordinator
memory.event
A shared team memory, the gateway’s sixth memory layer
100no
presence
A 60-second heartbeat with what the operator is looking at
5by coordinator
team.join.request
An invitee’s signed request to the coordinator
3no
team.member.update
A role or status change for a member
20by coordinator
team.bulletin
A team-wide notice; relayed and logged on every instance the coordinator reaches, no receive handler yet
5by coordinator
team.coordinator.transfer
A signed hand-over of the coordinator role
60no

One send instead of N−1, without a middleman you have to trust.

Team-wide kinds go to the coordinator once. The coordinator fans them out. Because the original signature travels with the envelope, every peer verifies Alice, not the coordinator.

Alice (member)  ──plan.event──►  Coordinator  ──plan.event──►  Bob
                                             ──plan.event──►  Carol
                                             ──plan.event──►  Dan

signature on every arrow: Alice's. The coordinator forwards, it does not re-sign.
Fan-out as implemented in packages/gateway/src/federation/relay.ts (RELAYABLE_KINDS). The last line is ours.
Relay rules
  • →Only envelopes carrying a teamId this instance coordinates.
  • →Only plan.event, presence, team.bulletin, team.member.update. Direct messages, handoffs and approvals are point-to-point.
  • →Never back to the sender, never to itself. Loop prevention by identity, not by hop count.
  • →Fire-and-forget. A slow peer never blocks the inbox response.
Changing coordinator

The current coordinator signs a transfer record naming an active member, applies it locally, and broadcasts it as team.coordinator.transfer. Each receiver checks two things before updating its own team row: the record is signed by the key it currently holds as coordinator, and it has not seen this transfer id before. The record is kept in an audit table on every instance.

A roster of public keys, joined by signed invite.

A team is a list of { humanId, publicKey, instanceUrl, role, status } rows on each member’s instance. Roles are owner, operator, member, viewer. Status is active, inactive, or revoked.

An invite is a 24-byte random token plus the team name, role, coordinator URL and a 7-day expiry, signed by the inviter. It is shipped inside the URL fragment (after the #), which browsers never send to servers, so the blob does not land in anyone’s access logs. The invitee’s instance verifies the signature offline before it posts a join request.

Join
Invitee POSTs team.join.request to the coordinator with its own public key and instance URL. The coordinator checks the invite exists, matches the team, is unexpired and unconsumed, then upserts the member and answers with the roster.
Presence
Every 60 seconds each instance sends a presence envelope to every active peer, carrying which plan, task or agent the operator is on. Silence for 5 minutes marks a peer idle; 30 minutes, offline.
Snapshot
Any member can export a signed team snapshot: roster, recent plan events, shared memories, coordinator history. Used to onboard a new member with context or to re-seed an instance after a disk loss.
Delivery
Sends are HTTPS POSTs with a 15-second timeout, retried at 0, 2 and 6 seconds on 5xx or network errors, never on 4xx. After four attempts the send is recorded as failed. There is no store-and-forward queue for a peer that is offline.

What it stops, and what it doesn’t yet.

Read the second column before you trust the first. These are the limits as of the current gateway; we would rather you plan around them than discover them.

Defended
Replay
Nonce dedup for 24 hours plus a ±60-minute freshness window on sentAt. A captured envelope is refused twice over.
Impersonation, inside a team
For team-scoped kinds the signature must verify against a public key that is on the roster, and a presence ping is applied only under the handle that key owns. The human-readable handle carries no authority.
Forged membership
Invites are signed by the inviter and checked by the coordinator; a rogue machine cannot mint one.
Relay tampering
The coordinator forwards bytes it cannot re-sign. Peers verify the origin.
Coordinator hijack
A transfer must be signed by the key currently on file as coordinator and is idempotent by id.
Flooding
Per-key, per-kind caps are checked before any cryptography, so junk never reaches the Ed25519 path, and counted only after verification, so nobody can spend another member’s budget.
Not defended, by design today
Payload confidentiality
Envelopes are signed, not encrypted. TLS protects the wire; the coordinator reads what it relays. Pass vault names in payloads, never the secrets themselves.
Key at rest
The private key is a row in your instance’s SQLite database. Whoever can read that file is you. Disk permissions and the systemd sandbox on the VPS install are the guard; there is no hardware-key or OS-keychain integration yet.
Rotation and revocation
No key-rotation command yet: replacing a compromised key means a new identity and re-joining your teams, because every roster trusts the public key. A compromised member is handled by the coordinator marking it revoked, which reaches peers on the next relay.
Transport trust
HTTPS with the system CA store. No certificate pinning between instances.
Metadata
Who talks to whom, how often, and which kinds is visible to every peer that receives the envelope, including the coordinator.
Out-of-team direct messages
An agent.message with teamId null is accepted from any key that verifies; there is no allow-list for strangers yet. The 30-per-minute per-key cap is the only spam control on that path.

About 2,500 lines. Here is where each claim lives.

Everything above lives under packages/gateway/src/ in every install, under the MIT license, and the protocol layer is published on its own. Line counts are from the tree this page was written against.

federation/identity.ts204keypair, canonical JSON, sign, verify
federation/outbox.ts147envelope shape, send with retries
federation/security.ts136freshness, nonce dedup, rate limits
federation/relay.ts84coordinator fan-out
federation/invites.ts232signed invites, URL-fragment encoding
federation/teams.ts260teams and member roster
federation/presence.ts188heartbeat, idle/offline transitions
federation/coordinator-transfer.ts188signed hand-over ceremony
federation/snapshot.ts83signed team export
api/federation.ts964the inbox route and per-kind handlers
Verify this page’s envelope
// verify.mjs: Node 18+, no dependencies
import { createPublicKey, verify } from 'node:crypto';

const env = {
  kind: 'agent.handoff.requested',
  teamId: '2f1c9c1e-6a5d-4a0e-9e2b-7c4b7d3f1a90',
  fromHumanId: 'alice@viox.ai',
  fromPublicKey:
    '3f4bf05ec51a5f66911df69ff91d17c6' +
    '3ce1235583333e1a10310b39833a90a5',
  fromInstanceUrl: 'https://os.alice.example',
  payload: {
    "originHandoffId": "8c6b2e51-3d0f-4b7a-9f1e-5a2c6d7e8f90",
    "fromAgent": "closer",
    "toAgent": "voice",
    "summary": "Rewrite the Acme landing-page draft in their brand voice",
    "context": {
      "draftUrl": "vault://acme/landing-v3.md",
      "dueBy": "2026-09-09"
    }
  },
  nonce: '6e0b0c3a-1f7b-4d1e-9a5c-2b9f0d8e7c41',
  sentAt: '2026-09-07T14:02:11.418Z',
  signature:
    '593ae86310fa70ee917424aac17ff16420b44ece0a14562b95903cc83f1f75fa' +
    '05a19c42434ddd9a5be99610376b9d6812b60ac6b414556ac4cbb3c79547e40b',
};

const canon = (v) => {
  if (v === null || typeof v !== 'object') return JSON.stringify(v);
  if (Array.isArray(v)) return '[' + v.map(canon).join(',') + ']';
  return '{' + Object.keys(v).sort()
    .map((k) => JSON.stringify(k) + ':' + canon(v[k]))
    .join(',') + '}';
};

const { signature, ...signed } = env;
// SubjectPublicKeyInfo prefix for a raw Ed25519 point
const spki = Buffer.concat([
  Buffer.from('302a300506032b6570032100', 'hex'),
  Buffer.from(env.fromPublicKey, 'hex'),
]);
const key = createPublicKey({ key: spki, format: 'der', type: 'spki' });

const ok = verify(
  null,
  Buffer.from(canon(signed)),
  key,
  Buffer.from(signature, 'hex'),
);
console.log(ok); // true
Paste into verify.mjs and run `node verify.mjs`. Change one character of the payload and it prints false.
Knock on your own inbox
curl -s -X POST https://YOUR-INSTANCE/api/federation/inbox \
  -H 'content-type: application/json' \
  -d '{
    "kind": "presence",
    "fromHumanId": "mallory@example",
    "fromPublicKey": "00",
    "signature": "00",
    "nonce": "'$(uuidgen)'",
    "sentAt": "'$(date -u +%FT%TZ)'"
  }'

# → 401 {"error":"signature_invalid"}
#   plus one row in federation_inbox with verified = 0
A fresh nonce and timestamp pass the gate; the zero signature does not. The attempt is logged.

Every file in the table is published verbatim, with the inbox route, the schema and this verify script, at github.com/juan-viox/viox-federation under MIT. The rest of the platform is source-available on request. The rest of the platform, and what it costs to run, is on the platform page.

Run one instance. Then invite a second.

Pilot seats are $497/mo plus a one-time $997 setup, five this quarter. They include the VPS install and a walk through the first handoff between your instance and a teammate’s.