Complete implementation reference for the DiviDen Relay Protocol. This document is the source of truth for any federated instance (FVP, third-party, or self-hosted) implementing cross-instance relay exchange. It covers every endpoint, field, state, edge case, and expectation.
A Relay is a structured, user-scoped message exchanged between two connected DiviDen instances. It is the core A2A (agent-to-agent) primitive. Every relay belongs to a Connection, carries atype and intent, moves through a defined lifecycle, and may be part of a multi-turn thread.
| Field | Type | Description |
|---|---|---|
Connection | resource | A bilateral, federated link between two users on different instances. Holds the shared federationToken used to authenticate relays. |
Relay | resource | The message itself — has type, intent, subject, payload, priority, status, threadId. |
Instance | server | A running deployment (dividen.ai, fvp.app, self-hosted). |
peerRelayId | string | The relay ID on the REMOTE instance. Each side stores the other side's ID for correlation. |
threadId | string | The root relay ID that identifies a conversation across instances. |
Ambient | modifier | A low-priority relay mode designed to be woven into conversation rather than interrupt. See §10. |
Direct | modifier | Non-ambient relay. Surfaces immediately in the operator's comms. |
Federation Token | secret | A shared secret established during handshake. Sent as x-federation-token header on every relay. |
/api/federation/relay-ack.Before any relay can flow, both instances must share an active Connection and afederationToken. The handshake is two legs:
When a user on instance A requests a connection to a user on instance B, instance A:
federationToken (at least 32 bytes of entropy).Connection row with status="pending", isFederated=true./api/federation/connect.POST https://{peer-instance}/api/federation/connect
Content-Type: application/json
{
"fromInstanceUrl": "https://dividen.ai",
"fromInstanceName": "DiviDen",
"fromUserEmail": "alice@dividen.ai",
"fromUserName": "Alice",
"toUserEmail": "bob@fvp.app",
"federationToken": "<random-secret-at-least-32-bytes>",
"connectionId": "<local-connection-cuid>"
}When instance B auto-approves (or when user B manually accepts later), B POSTs to A's/api/federation/connect/accept:
POST https://{sender-instance}/api/federation/connect/accept
Content-Type: application/json
X-Federation-Token: <token-from-step-2.1>
{
"connectionId": "<local-connection-id-on-B>",
"acceptedByEmail": "bob@fvp.app",
"acceptedByName": "Bob",
"instanceUrl": "https://fvp.app"
}After this callback, both sides have status="active" and may exchange relays.
federationToken and validate it on every inbound request via thex-federation-token header. Tokens are per-connection, not per-user or per-instance.Each instance persists relays in its own database. The canonical DiviDen schema (Prisma) is below. External implementations MUST preserve the semantics of every field, but are free to use whatever storage engine they prefer.
model AgentRelay {
id String @id // cuid or uuid — local-only
connectionId String // FK to Connection
fromUserId String // Sender (local user or placeholder for federated inbound)
toUserId String? // Recipient (local user, null if unrouted)
direction String // "outbound" | "inbound"
type String // "request" | "response" | "notification" | "update"
intent String // See §4
subject String // Human-readable one-liner
payload String? // JSON string — structured data
status String // See §5
priority String // "urgent" | "normal" | "low"
dueDate DateTime?
resolvedAt DateTime?
responsePayload String? // JSON string — reply data
threadId String? // Root relay ID for multi-turn threads (§11)
parentRelayId String? // Direct parent relay
artifactType String? // "text"|"code"|"document"|"data"|"contact_card"|"calendar_invite"|"email_draft"
artifacts String? // JSON array of typed artifact objects
peerRelayId String? // Relay ID on the REMOTE instance (correlation key)
peerInstanceUrl String? // Remote instance base URL
queueItemId String? // Optional bridge to local task queue
cardId String? // Optional Kanban card this relay operates on
teamId String?
projectId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}(connectionId, peerRelayId) uniquely identifies a relay across instances. This pair is used for idempotency (§14) and for ack routing.| Field | Type | Description |
|---|---|---|
request | string | Default. Asks the recipient to do something or provide information. Expects an ack + eventual response. |
response | string | A reply to a previous relay. parentRelayId MUST be set. |
notification | string | One-way message. No response expected, but ack still returned. |
update | string | State change on a shared card or thread. Usually followed by a card-update webhook. |
Intent is the semantic purpose of the relay. Senders SHOULD use one of the known values; receivers MUST tolerate unknown intents by treating them as custom.
| Field | Type | Description |
|---|---|---|
get_info | string | Ask the peer for a piece of information. Short-form response expected. |
assign_task | string | Delegate a task. Receiver SHOULD create a Kanban card (§13). |
delegate | string | Alias for assign_task with stronger ownership transfer semantics. |
request_approval | string | Request go/no-go sign-off on a decision. |
share_update | string | Informational status share. Often used with ambient mode. |
schedule | string | Propose a meeting or time slot. Triggers calendar intent on the peer. |
introduce | string | Introduce a contact OR invite someone into a scoped workspace (project, team). Payload includes a contact_card OR a {kind:'project_invite'} block (v2.3.1). |
ask | string | (v2.2.0) Open-ended question, often ambient. |
opinion | string | (v2.2.0) Solicit a judgement or review. |
note | string | (v2.2.0) Drop a quiet note — lowest priority ambient. |
intro | string | (v2.2.0) Informal intro variant of `introduce`. |
custom | string | Catch-all. Receiver treats the subject + payload as free text. |
Project invites use intent='introduce' with a structured payload. This allows a receiver that knows about projects to surface it as a real invite (bell count, queue item, dashed avatar on card, Accept/Decline buttons) while still degrading gracefully on a receiver that only understands introduce as a generic contact intro.
{
"relayType": "request",
"intent": "introduce",
"direction": "outbound",
"status": "delivered",
"subject": "Invite to \"Q3 Rebrand\"",
"payload": {
"kind": "project_invite",
"inviteId": "cmp_xyz",
"projectId": "proj_abc",
"projectName": "Q3 Rebrand",
"role": "contributor",
"message": "Optional note from inviter",
"inviterName": "Jon Bradford"
}
}Receivers SHOULD treat payload.kind === 'project_invite' as the canonical invite discriminator and mirror the same record set (ProjectInvite, QueueItem, CommsMessage) locally when ingested. The invite is accepted or declined via PATCH /api/project-invites with{ inviteId, action }.
Senders MUST enforce uniqueness — a duplicate POST /api/projects/[id]/invite returns409 { code: 'ALREADY_INVITED', inviteId }. To deliberately rotate a stale invite, resend with{ force: true } in the body; the old invite + queue item + relay + comms message are cancelled and a fresh set is created. The response surfaces replacedInviteId so downstream listeners can reconcile.
A relay moves through a deterministic state machine. Each side tracks its own status independently; the sender's status is authoritative and advanced by ack callbacks.
Sender side Receiver side
=========== =============
(create)
↓
pending ────(push)───→ POST /api/federation/relay
↓ ↓
(200 ack) delivered
↓ ↓
delivered (user/agent acts)
↓ ↓
(wait for ack-back) completed | declined
↓ ↓
completed|declined ←──(ack)── POST /api/federation/relay-ack
↓
resolvedAt set
queueItem → done_today (if linked)
checklist → updated
webhook emitted| Field | Type | Description |
|---|---|---|
pending | status | Created locally, not yet pushed (or push in flight). |
delivered | status | Remote instance acknowledged receipt (HTTP 200 from /api/federation/relay). |
agent_handling | status | (Optional) Recipient's agent is actively working on the relay. |
user_review | status | (Optional) Awaiting recipient operator approval. |
completed | status | Terminal. Receiver completed the task. resolvedAt set. |
declined | status | Terminal. Receiver declined (or sender dismissed). resolvedAt set. |
expired | status | Terminal. Passed dueDate without resolution. Sender may retry. |
delivered, completed, ordeclined on the sender side, it MUST NOT be pushed again. See §14.All federation endpoints live under /api/federation/*. The v2 namespace at /api/v2/relayexists as a proxy for instances advertising v2Relay capability — it forwards identically.
/api/federation/connectReceive a connection handshake request
/api/federation/connect/acceptReceive the acceptance callback from a peer
/api/federation/relayReceive a relay from a peer
/api/federation/relay-ackReceive a completion/response ack from a peer
/api/v2/relayv2 alias — proxies to /api/federation/relay
/api/federation/card-update(optional) Receive Kanban card state changes for mirrored cards
{peer}/api/federation/relayPush a relay to a peer
{peer}/api/federation/relay-ackSend completion/response back to originator
{peer}/api/federation/card-updateNotify peer of Kanban card state change
x-federation-token: <per-connection-secret>. Reject with HTTP 401 if missing, 404 if token does not map to an active connection.DiviDen uses two distinct token types for federation. Using the wrong token type on a route returns a diagnostic error code.
| Route | Header | Token Type | Notes |
|---|---|---|---|
| /api/federation/relay | x-federation-token | Connection token | Per-connection shared secret |
| /api/federation/relay-ack | x-federation-token | Connection token | Per-connection shared secret |
| /api/federation/connect/accept | x-federation-token | Connection token | Handshake callback |
| /api/v2/federation/heartbeat | Authorization: Bearer | Platform token | Instance-level identity |
| /api/v2/federation/agents (GET) | Either | Platform or Connection | Read-only catalog browse |
| /api/v2/federation/agents (POST) | Either | Platform or Connection + trust | Publish requires isTrusted |
| /api/v2/federation/register | Authorization: Bearer | Platform token | Initial instance registration |
| /api/v2/federation/capabilities | Authorization: Bearer | Platform token | Declare instance features |
| /api/v2/federation/validate-payment | Either | Platform or Connection | Cross-instance payment proof |
| /api/v2/federation/marketplace-link | Authorization: Bearer | Platform token | Deep-link resolution |
x-federation-token to a platform route (e.g. /v2/federation/heartbeat) returns { code: "relay_token_used_for_platform_route" }. Platform routes require Authorization: Bearer <platformToken>.The recipient instance exposes this endpoint. It receives, validates, persists, and surfaces the relay to the operator.
| Field | Type | Description |
|---|---|---|
Content-Type* | string | application/json |
x-federation-token* | string | Per-connection shared secret established during handshake. |
| Field | Type | Description |
|---|---|---|
connectionId* | string | Sender's local connection ID (echoed back for logging/diagnostics — NOT used for auth). |
relayId* | string | Sender's local relay ID. Stored by the receiver as `peerRelayId` for correlation. |
fromUserEmail* | string | Sender's email on the originating instance. Used for display + sender identity persistence. |
fromUserName | string | Sender's display name. |
toUserEmail* | string | Recipient's email on THIS instance. If no local user matches, receiver routes to the connection owner (fallback). |
type | string | See §4.1. Default: `request`. |
intent | string | See §4.2. Default: `custom`. |
subject* | string | One-line human-readable summary (becomes card title, comms prefix, etc). |
payload | object | string | Structured data. Object OR JSON-encoded string (receiver normalizes). |
priority | string | `urgent` | `normal` | `low`. Default: `normal` (or `low` if ambient). |
dueDate | ISO8601 | Optional deadline. |
threadId | string | Shared thread root. See §11. |
parentRelayId | string | Sender's ID of the parent relay. Receiver maps this to its local parent via peerRelayId. |
attachments | array | Up to 10 attachment objects. Can also be nested inside payload.attachments. See §12. |
callbackUrl | URL | Sender's `/api/federation/relay-ack` URL. Receiver uses this to push back completion acks. |
teamId | string | (v2.3.2) Sender's team ID — routing scope. Receiver validates against local Team rows; unknown IDs are dropped silently + echoed as `scopeDropped.teamId` in §7.4 response. |
projectId | string | (v2.3.2) Sender's project ID — routing scope. Receiver validates against local Project rows. If projectId resolves and has a teamId, it inherits as scope automatically. |
The payload field is free-form JSON, but DiviDen reserves these underscore-prefixed keys for cross-instance metadata. Receivers MUST preserve them on round-trips.
| Field | Type | Description |
|---|---|---|
_ambient | boolean | If true, relay is ambient mode (§10). Routes silently, surfaces via weaving not notification. |
_context | string | Conversational context that prompted the relay. Helps the receiving agent weave naturally. |
_topic | string | Topic tag. Used by recipient topic filters to opt out. |
_instruction | string | Optional directive for how the receiving agent should handle the relay. |
_sender | object | Receiver POPULATES this on ingestion with {name,email,instanceUrl,connectionId,isFederated:true} so the local agent can resolve sender identity without a local user row. Senders SHOULD NOT set this. |
attachments | array | Alternative location for attachment list (§12). |
Always HTTP 200 on accepted relays. Use fields to signal routing outcomes:
// Normal accept
{
"success": true,
"relayId": "clx...", // receiver's local relay ID (sender stores as peerRelayId)
"ambient": false,
"cardId": null, // populated if task intent → Kanban
"threadId": "clx...", // echoed so sender can thread
"parentRelayId": null,
"attachmentCount": 0,
"fallback": false, // true if toUserEmail had no match, routed to connection owner
// v2.3.2: scope echo
"scopeResolved": { // what the receiver actually persisted
"teamId": "cm_recv_team_x", // null if no scope resolved or sent
"projectId": "cm_recv_proj_y"
},
"scopeDropped": { // fields sender sent but receiver couldn't resolve locally
"teamId": null, // null means no drop; string means the dropped ID
"projectId": null
}
}
// Idempotent duplicate
{ "success": true, "duplicate": true, "relayId": "clx..." }
// Ambient silently filtered by recipient preferences
{ "ok": true, "filtered": true, "reason": "quiet_hours" }x-federation-token against an active connection. 401/404 on failure.FederationConfig.allowInbound. 403 if disabled.(peerRelayId, connectionId) already exists, return early with duplicate:true.payload._sender with sender identity.payload._ambient or (intent="share_update" && priority="low").toUserEmail → local user; fallback to connection requester).peerRelayId lookup.AgentRelay row with status="delivered".leads column.CommsMessage (ambient: low priority auto-read; direct: new).relayId and threadId.When the sender includes teamId and/or projectId on the envelope, the receiver runs a deterministic resolution step. Scope is advisory, not strict — an unresolvable ID never rejects the relay. The receiver simply drops that field and echoes the drop.
teamId is present, look up Team by ID on the local instance. Drop if not found.projectId is present, look up Project. Drop if not found.projectId resolved and its project.teamId is set, and the sender did NOT provide a teamId (or we dropped it), inherit project.teamId as the scope team.AgentRelay row (teamId, projectId).KanbanCard, also persist projectId on the card (Kanban is project-scoped only — no teamId on card).scopeResolved + scopeDropped in the 200 response.Peers that don't implement scope continue to work unchanged — both fields are optional on the wire and omitted fields skip the entire resolution step. A future strictScope: true flag on the connection record will let operators opt in to bounce-on-drop behavior; it is NOT enabled by default.
The ambient filter table (see §10) now accepts object-form entries in addition to legacy topic strings:
// Legacy: opts-out of all "engineering" topic ambient
ambientFilters: ["engineering"]
// v2.3.2: opts-out only of ambient scoped to project cm_proj_xyz
ambientFilters: [{ topic: "engineering", projectId: "cm_proj_xyz" }]
// v2.3.2: opts-out of any ambient scoped to a specific team
ambientFilters: [{ teamId: "cm_team_abc" }]When an object-form filter is used, ALL specified fields must match the incoming relay for the filter to trigger. A filter of { topic: "eng", teamId: "X" } only blocks ambient with BOTH topic=eng AND teamId=X — narrower filtering, less bleed.
When the receiving operator (or agent) acts on a relay — completing it, responding, or declining — the receiver pushes the outcome back to the sender via this endpoint. This is how the sender closes the loop.
| Field | Type | Description |
|---|---|---|
relayId* | string | The ORIGINATING instance's relay ID (what the sender called `relayId` in §7.2 — receiver has it stored as `peerRelayId`). This is the ID the sender will look up locally. |
localRelayId | string | The receiver's own relay ID (informational, for debugging). |
status* | string | `completed` | `declined` — terminal state. |
responsePayload | string | object | The reply content. Can be string or JSON. |
subject | string | (Optional) updated subject line for display. |
timestamp | ISO8601 | When the status changed on the receiver side. |
On ack receipt, the sender instance:
status, responsePayload, resolvedAt.federation_relay_completed activity.CommsMessage to the operator summarizing the outcome.done_today.delegationStatus and completed.relay.state_changed webhook to local subscribers.delivered on the sender side. The receiver SHOULD retry with exponential backoff.To send a relay TO a peer, POST to their /api/federation/relay endpoint. The DiviDen reference implementation (pushRelayToFederatedInstance) encodes all the guarantees below; mirror them.
peerRelayId is already set, OR status is delivered|completed|declined, SKIP the push. This prevents duplicate delivery (§14).isFederated=true, status="active", and has a federationToken.POST https://{peer}/api/federation/relay
Content-Type: application/json
x-federation-token: <connection.federationToken>
{
"connectionId": "<sender-local-connection-id>",
"relayId": "<sender-local-relay-id>",
"fromUserEmail": "alice@dividen.ai",
"fromUserName": "Alice",
"toUserEmail": "bob@fvp.app",
"type": "request",
"intent": "assign_task",
"subject": "Draft the Q2 briefing",
"priority": "normal",
"dueDate": "2026-04-25T17:00:00Z",
"threadId": "<thread-root-id-if-continuing>",
"parentRelayId": "<parent-sender-relay-id-if-reply>",
"payload": {
"description": "Pull the numbers from dashboard and draft 500 words.",
"_context": "Jon asked about the quarterly rollup in chat",
"_topic": "briefing"
},
"attachments": [
{ "name": "q1.pdf", "url": "https://cdn.example/q1.pdf", "size": 120000, "mimeType": "application/pdf" }
],
"callbackUrl": "https://dividen.ai/api/federation/relay-ack"
}relayId as your peerRelayId.peerRelayId, peerInstanceUrl, status="delivered".federation_relay_acked activity.expired or surface a reconnect prompt to the operator.pending during retries.expired and notify the operator.Ambient relays are the defining feature of DiviDen. They let two agents exchange low-priority context that is never announced as a notification — instead, the receiving agent HOLDS the content and weaves it naturally into the operator's next relevant conversation.
A relay is ambient if ANY of the following:
payload._ambient === true (preferred, explicit)payload.ambient === true (legacy)intent === "share_update" && priority === "low" (implicit)Before persisting an ambient relay, the receiver checks the recipient's UserProfile preferences. If any gate blocks, return HTTP 200 with { ok: true, filtered: true, reason: "..." } and DO NOT create a record.
| Field | Type | Description |
|---|---|---|
relayMode === 'off' | gate | Reject: `relay_mode_off` |
relayMode === 'minimal' | gate | Reject: `relay_mode_minimal_blocks_ambient` |
allowAmbientInbound === false | gate | Reject: `ambient_inbound_disabled` |
relayTopicFilters contains payload._topic | gate | Reject: `topic_filtered:{topic}` |
now within relayQuietHours | gate | Reject: `quiet_hours` |
The receiving instance's agent MUST follow these rules. DiviDen enforces them via the system prompt; external implementations should encode the equivalent behavior.
payload._topic or payload._context, weave the ambient content into your reply naturally.payload._sender.name — e.g. "FVP mentioned earlier that the weather is 72°F...".relay_respond without announcing "I sent a reply".DiviDen's reference rendering:
| Field | Type | Description |
|---|---|---|
Direct (purple) | card | Immediately visible in comms feed. `new` state — beeps/banner. Purple card in ChatView. |
Ambient () | card | Auto-marked `read` — silent. Appears in comms history but never triggers banner. Can be dismissed via ×. |
Outgoing (green) | card | Sender's view of relays they pushed. Shows target, status, footnote with dismiss option. |
Relays form threads when the sender sets parentRelayId and/or threadId. Receivers MUST preserve thread continuity across instances.
Thread IDs are sender-assigned but echoed on every relay. The receiver stores the thread ID as-is; parent resolution uses peerRelayId lookup.
Sender sends reply:
{
"relayId": "sender-r2",
"threadId": "sender-r1", // root relay id on sender
"parentRelayId": "sender-r1", // sender's id of parent
...
}
Receiver resolution:
1. Look up local parent: AgentRelay where peerRelayId="sender-r1" AND connectionId=conn
2. If found: parentRelayId = localParent.id, threadId = localParent.threadId || "sender-r1"
3. If not found: threadId = "sender-r1" (best-effort), parentRelayId = null
Receiver persists with its own local IDs; echoes threadId="sender-r1" back in the response
so the sender can confirm continuity.If no parent resolves and no threadId is supplied, the receiver treats the new relay as its own thread root — setting threadId = relay.id.
Attachments are PUBLIC-URL references. Instances do NOT exchange binary payloads inline — files live on the sender's CDN (S3-compatible signed URL or public bucket). Maximum 10 per relay.
| Field | Type | Description |
|---|---|---|
name* | string | Display filename. Fallback chain: name → filename → 'attachment'. |
url* | string | Publicly accessible URL. Required — attachments without a URL are dropped. |
size | integer | Bytes. Optional. |
mimeType | string | MIME type. Aliases: mime, type. |
Attachments can live in body.attachments OR payload.attachments. Receivers MUST accept both and normalize — the reference implementation folds them back into payload.attachmentsfor persistence so round-trips preserve them.
DiviDen renders attachments as a markdown list appended to the comms message:
Attachments:
• [q1.pdf](https://cdn.example/q1.pdf) (117 KB)
• [chart.png](https://upload.wikimedia.org/wikipedia/commons/0/06/Communication_Diagram.png) (42 KB)When intent is assign_task, delegate, schedule, or request_approval, AND the relay is not ambient, the receiver SHOULD auto-create a task card in its intake column (DiviDen uses leads).
| Field | Type | Description |
|---|---|---|
title | string | subject |
description | string | payload.description || payload.body || payload.message || fallback |
status | string | 'leads' (intake) |
priority | string | Mapped from relay.priority: urgent→urgent, normal→medium, low→low |
assignee | string | 'human' by default (the operator decides to delegate to agent) |
dueDate | ISO8601 | From relay.dueDate |
sourceRelayId | string | FK back to the relay (so card updates ack back) |
order | integer | Max(order) + 1 in leads column — newest on top |
If the operator moves the card through their pipeline, the receiver SHOULD push acard-update webhook back to the sender so both boards stay in sync:
POST {sender}/api/federation/card-update
x-federation-token: <connection-token>
{
"peerCardId": "<receiver-local-card-id>", // sender will correlate via CardLink
"localCardId": "<sender-peer-card-id>",
"relayId": "<sender-peer-relay-id>",
"peerRelayId": "<receiver-local-relay-id>",
"newStage": "active",
"newPriority": "high",
"title": "Draft Q2 briefing — in progress",
"reason": "pulled in by Bob",
"fromUserName": "Bob",
"fromUserEmail": "bob@fvp.app",
"timestamp": "2026-04-18T14:32:00Z"
}v2.3.0 hardened the relay loop. Both sides now enforce strict idempotency. Failing either check has caused duplicate peer records and surfacing loops in the past — do not skip.
// On POST /api/federation/relay
if (body.relayId) {
const existing = await db.AgentRelay.findFirst({
where: { peerRelayId: body.relayId, connectionId: conn.id },
});
if (existing) {
// Respond 200, no new record, no side effects.
return NextResponse.json({
success: true,
duplicate: true,
relayId: existing.id,
});
}
}// Before pushing, check the local relay state
const local = await db.AgentRelay.findUnique({ where: { id: relayId } });
if (local.peerRelayId ||
['delivered','completed','declined'].includes(local.status)) {
// SKIP — already pushed successfully. DO NOT push again.
return true;
}
// After successful push (HTTP 200 from peer):
await db.AgentRelay.update({
where: { id: relayId },
data: {
peerRelayId: ack.relayId,
peerInstanceUrl: conn.peerInstanceUrl,
status: 'delivered', // CRITICAL — without this, re-surfacing causes re-push
},
});status="delivered" stamp on ack, the local relay stayed pending forever. Every conversation pass re-surfaced it, and the agent re-dispatched, creating duplicate records on the peer and a visible "relay loop". This is now blocked on both sides.Operators can dismiss a relay from either side. Dismissal is a local action with a federation ack-back.
/api/relays/{id}/dismissAuth: session. Terminates the relay locally and optionally pushes ack-back.
POST /api/relays/{id}/dismiss
{ "reason": "not relevant anymore" } // optional
// Effects:
// 1. relay.status = 'declined'
// 2. relay.resolvedAt = now
// 3. relay.responsePayload = '(dismissed by operator: {reason})'
// 4. If peerRelayId + peerInstanceUrl: POST to {peer}/api/federation/relay-ack
// with status='declined' so the other side also resolves.
// 5. Activity log + webhook emitted.In DiviDen, the dismiss × button lives in the relay footnote rendered by the shared<RelayFootnote/> component. It is visible on both purple (inbound) and green (outbound) cards when the relay is not yet terminal.
Federated inbound relays do NOT have a matching local user row on the receiver. To let the receiving agent resolve the sender naturally ("your FVP account", "Bob at FVP"), the receiver MUST populate a_sender block inside payload during ingestion.
// Receiver populates this during /api/federation/relay ingestion
payload._sender = {
name: fromUserName || null,
email: fromUserEmail || null,
instanceUrl: connection.peerInstanceUrl,
connectionId: connection.id,
isFederated: true,
};
// Agent's system prompt then uses canonical helpers:
resolveSender(payload) → "your FVP account (bob@fvp.app)"
resolveRecipient(relay) → "you"payload._sender. Receivers overwrite any value to prevent spoofing.Every user has relay preferences on their UserProfile. These control gating (§10.2) and downstream surfacing behavior.
| Field | Type | Description |
|---|---|---|
relayMode | enum | 'full' | 'selective' | 'minimal' | 'off' — master switch. 'off' blocks ALL relays. 'minimal' blocks ambient. |
allowAmbientInbound | bool | Default true. Set false to reject ambient relays. |
allowAmbientOutbound | bool | Whether this user's Divi is allowed to send ambient relays. |
allowBroadcasts | bool | Whether this user receives broadcast relays. |
allowAmbientSurveys | bool | Opt-in to the AmbientSurveys marketplace agent. |
autoRespondAmbient | bool | Divi auto-answers ambient relays without surfacing to operator. |
relayQuietHours | JSON | { start:"22:00", end:"08:00", timezone:"America/Chicago" } — suppresses non-urgent relays. |
relayTopicFilters | JSON array | ["sales","recruiting"] — opt-out topic tags. |
briefVisibility | enum | 'self' | 'connections' | 'public' — who can see reasoning briefs. |
showBriefOnRelay | bool | Attach assembled brief to outbound relays so recipients can inspect reasoning. |
| Field | Type | Description |
|---|---|---|
200 OK | response | Relay accepted. Check duplicate/filtered/fallback flags for routing outcome. |
400 Bad Request | response | Missing required field (connectionId, relayId, subject, etc). Body: { error }. |
401 Unauthorized | response | Missing x-federation-token header. |
403 Forbidden | response | Inbound federation disabled (FederationConfig.allowInbound=false). |
404 Not Found | response | Federation token does not match any active connection. |
429 Rate Limited | response | Per-connection rate limit hit. Implementations SHOULD return Retry-After header. |
500 Internal Error | response | Unexpected server error. Sender SHOULD retry with backoff. |
{ error: "human readable message" } — no structured error codes yet. v2.4 will add an errorCode enum for stable programmatic handling.Before declaring your instance compliant with DAWP v2.3.0 relay protocol, verify each item below with integration tests.
The relay protocol is versioned at the DAWP (DiviDen Agentic Working Protocol) level. Wire fields are additive — new optional fields may appear at any time. Breaking changes are major-bumped.
| Field | Type | Description |
|---|---|---|
v2.0 | release | Baseline: request/response/notification, threads, peerRelayId idempotency. |
v2.1 | release | Ambient protocol, topic filters, quiet hours, card intent auto-create. |
v2.2 | release | Attachments (max 10), threadId echo, flexible intents (ask, opinion, note, intro). |
v2.3 | release | Loop prevention (idempotency guard + status stamp), RelayFootnote, dismiss endpoint, sender resolution, 7-rule ambient HOLD/weave system prompt. |
v2.3.1 | release | Project invites use intent='introduce' with payload.kind='project_invite' (§4.3). Duplicate guard (409 ALREADY_INVITED) + force:true reinvite. Every invite now emits ProjectInvite + QueueItem + AgentRelay + CommsMessage in one transaction. |
v2.3.2 | release | Multi-tenant routing: top-level teamId/projectId on relay envelopes + notification envelopes (§7.2). Receiver runs scope resolution (§7.6) and echoes scopeResolved/scopeDropped in the response. Ambient gating accepts both string arrays and object-form filters (§7.7). Federated project invites now push via /api/federation/relay + /api/federation/notify (v2.3.1 wire gap closed). |
custom./api/federation/relay, inbound /api/federation/relay-ack, idempotency guard on both sides, ambient gating, and sender identity persistence. Everything else is optional or additive.@divi in shared chat.Download a plain-text copy of this page
Last updated: May 28, 2026