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.
{ "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.}
- kind
What this envelope means. Twelve kinds are rate-limited; eleven have a receive handler. team.bulletin is relayed and logged but not yet applied. Anything else is stored in the inbox log and never applied.
- teamId
Team scope, or null for a 1:1 message outside any team. Team-scoped kinds resolve the sender by public key inside this team before applying anything; a 1:1 agent.message is accepted from any key that verifies.
- fromHumanId
The operator handle. Display only: authorization keys off fromPublicKey, never off this string, and a team-scoped envelope is refused if the handle is not the one the roster binds to that key.
- fromPublicKey
32-byte Ed25519 public key, hex. Must match a roster row on the receiving instance for team-scoped kinds.
- fromInstanceUrl
Where replies go. A resolved handoff or an approval decision is POSTed back to this origin’s /api/federation/inbox.
- payload
Kind-specific body. For a handoff: which agent asked, which agent is asked, the one-line summary, and the context the target needs so nobody re-explains.
- nonce
Random UUID v4. Stored on arrival. A second envelope carrying the same nonce is refused with 409 for the next 24 hours.
- sentAt
ISO 8601. Must be within ±60 minutes of the receiver’s clock or the envelope is refused as stale, so a captured envelope cannot be replayed after its nonce ages out.
- signature
Ed25519 over the canonical JSON of the eight fields above, 64 bytes, hex. Verified against fromPublicKey before any handler runs.
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,
});
}// 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(',') +
'}'
);
}// 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;
}
}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.
- 1Shape400 malformed envelope
kind, fromHumanId, fromPublicKey and signature must all be strings. Nothing else is read until they are.400 malformed envelope
- 2Freshness400 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.400 stale
- 3Replay409 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.409 duplicate_nonce
- 4Rate429 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.429 rate_limited
- 5Signature401 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.401 signature_invalid
- 6Membershiplogged, 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.logged, not applied
- 7Apply200 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.200 ok
- 8Relaycoordinator only
If this instance coordinates the team and the kind is broadcastable, the envelope is forwarded byte-for-byte to every other active member.coordinator only
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.
| kind | carries | cap / min | relayed |
|---|---|---|---|
| agent.message | A direct message from one operator’s agent to another’s inbox | 30 | no |
| agent.handoff.requested | An @mention turned into a tracked work item on the target instance | 60 | no |
| agent.handoff.resolved | Completion or rejection, sent back to the origin instance | 60 | no |
| approval.requested | An agent needs a named human’s sign-off before it proceeds | 10 | no |
| approval.decided | The decision, mirrored back to whoever asked | 50 | no |
| plan.event | A Kanban or plan change, ordered by hybrid logical clock | 200 | by coordinator |
| memory.event | A shared team memory, the gateway’s sixth memory layer | 100 | no |
| presence | A 60-second heartbeat with what the operator is looking at | 5 | by coordinator |
| team.join.request | An invitee’s signed request to the coordinator | 3 | no |
| team.member.update | A role or status change for a member | 20 | by coordinator |
| team.bulletin | A team-wide notice; relayed and logged on every instance the coordinator reaches, no receive handler yet | 5 | by coordinator |
| team.coordinator.transfer | A signed hand-over of the coordinator role | 60 | no |
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.- →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.
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.
- 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.
- 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.ts | 204 | keypair, canonical JSON, sign, verify |
| federation/outbox.ts | 147 | envelope shape, send with retries |
| federation/security.ts | 136 | freshness, nonce dedup, rate limits |
| federation/relay.ts | 84 | coordinator fan-out |
| federation/invites.ts | 232 | signed invites, URL-fragment encoding |
| federation/teams.ts | 260 | teams and member roster |
| federation/presence.ts | 188 | heartbeat, idle/offline transitions |
| federation/coordinator-transfer.ts | 188 | signed hand-over ceremony |
| federation/snapshot.ts | 83 | signed team export |
| api/federation.ts | 964 | the inbox route and per-kind handlers |
// 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); // truecurl -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 = 0Every 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.