SPECv2.3.0Last updated: May 28, 2026

Relay Protocol Specification

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.

Audience: Engineering teams at FVP and other outside instances integrating with the DiviDen federation layer. If you are a user or a platform operator, see the Federation Guide instead.

1. Overview & Vocabulary

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.

FieldTypeDescription
ConnectionresourceA bilateral, federated link between two users on different instances. Holds the shared federationToken used to authenticate relays.
RelayresourceThe message itself — has type, intent, subject, payload, priority, status, threadId.
InstanceserverA running deployment (dividen.ai, fvp.app, self-hosted).
peerRelayIdstringThe relay ID on the REMOTE instance. Each side stores the other side's ID for correlation.
threadIdstringThe root relay ID that identifies a conversation across instances.
AmbientmodifierA low-priority relay mode designed to be woven into conversation rather than interrupt. See §10.
DirectmodifierNon-ambient relay. Surfaces immediately in the operator's comms.
Federation TokensecretA shared secret established during handshake. Sent as x-federation-token header on every relay.
Core invariant: The sending instance OWNS the relay lifecycle. The receiving instance accepts, delivers to its operator, and acks back. Status on the sender's side is the canonical truth. Responses flow back via /api/federation/relay-ack.

2. Connection Handshake

Before any relay can flow, both instances must share an active Connection and afederationToken. The handshake is two legs:

2.1 Initiation (outbound)

When a user on instance A requests a connection to a user on instance B, instance A:

  1. Generates a random federationToken (at least 32 bytes of entropy).
  2. Stores a local Connection row with status="pending", isFederated=true.
  3. POSTs to instance B's /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>"
}

2.2 Acceptance callback (inbound)

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.

Instances MUST store the peer's federationToken and validate it on every inbound request via thex-federation-token header. Tokens are per-connection, not per-user or per-instance.

3. Relay Data Model

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
}
Correlation key: (connectionId, peerRelayId) uniquely identifies a relay across instances. This pair is used for idempotency (§14) and for ack routing.

4. Relay Types & Intents

4.1 Type

FieldTypeDescription
requeststringDefault. Asks the recipient to do something or provide information. Expects an ack + eventual response.
responsestringA reply to a previous relay. parentRelayId MUST be set.
notificationstringOne-way message. No response expected, but ack still returned.
updatestringState change on a shared card or thread. Usually followed by a card-update webhook.

4.2 Intent

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.

FieldTypeDescription
get_infostringAsk the peer for a piece of information. Short-form response expected.
assign_taskstringDelegate a task. Receiver SHOULD create a Kanban card (§13).
delegatestringAlias for assign_task with stronger ownership transfer semantics.
request_approvalstringRequest go/no-go sign-off on a decision.
share_updatestringInformational status share. Often used with ambient mode.
schedulestringPropose a meeting or time slot. Triggers calendar intent on the peer.
introducestringIntroduce 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).
askstring(v2.2.0) Open-ended question, often ambient.
opinionstring(v2.2.0) Solicit a judgement or review.
notestring(v2.2.0) Drop a quiet note — lowest priority ambient.
introstring(v2.2.0) Informal intro variant of `introduce`.
customstringCatch-all. Receiver treats the subject + payload as free text.

4.3 Project invite sub-payload (v2.3.1)

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.

5. Lifecycle & Statuses

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
FieldTypeDescription
pendingstatusCreated locally, not yet pushed (or push in flight).
deliveredstatusRemote instance acknowledged receipt (HTTP 200 from /api/federation/relay).
agent_handlingstatus(Optional) Recipient's agent is actively working on the relay.
user_reviewstatus(Optional) Awaiting recipient operator approval.
completedstatusTerminal. Receiver completed the task. resolvedAt set.
declinedstatusTerminal. Receiver declined (or sender dismissed). resolvedAt set.
expiredstatusTerminal. Passed dueDate without resolution. Sender may retry.
Idempotency rule: Once a relay reaches delivered, completed, ordeclined on the sender side, it MUST NOT be pushed again. See §14.

6. Endpoints

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.

Inbound (your instance exposes these)

POST
/api/federation/connect

Receive a connection handshake request

body.federationToken
POST
/api/federation/connect/accept

Receive the acceptance callback from a peer

x-federation-token
POST
/api/federation/relay

Receive a relay from a peer

x-federation-token
POST
/api/federation/relay-ack

Receive a completion/response ack from a peer

x-federation-token
POST
/api/v2/relay

v2 alias — proxies to /api/federation/relay

x-federation-token
POST
/api/federation/card-update

(optional) Receive Kanban card state changes for mirrored cards

x-federation-token

Outbound (your instance calls peers)

POST
{peer}/api/federation/relay

Push a relay to a peer

POST
{peer}/api/federation/relay-ack

Send completion/response back to originator

POST
{peer}/api/federation/card-update

Notify peer of Kanban card state change

Auth header: All inbound relays are authenticated by x-federation-token: <per-connection-secret>. Reject with HTTP 401 if missing, 404 if token does not map to an active connection.

Token Roles

DiviDen uses two distinct token types for federation. Using the wrong token type on a route returns a diagnostic error code.

RouteHeaderToken TypeNotes
/api/federation/relayx-federation-tokenConnection tokenPer-connection shared secret
/api/federation/relay-ackx-federation-tokenConnection tokenPer-connection shared secret
/api/federation/connect/acceptx-federation-tokenConnection tokenHandshake callback
/api/v2/federation/heartbeatAuthorization: BearerPlatform tokenInstance-level identity
/api/v2/federation/agents (GET)EitherPlatform or ConnectionRead-only catalog browse
/api/v2/federation/agents (POST)EitherPlatform or Connection + trustPublish requires isTrusted
/api/v2/federation/registerAuthorization: BearerPlatform tokenInitial instance registration
/api/v2/federation/capabilitiesAuthorization: BearerPlatform tokenDeclare instance features
/api/v2/federation/validate-paymentEitherPlatform or ConnectionCross-instance payment proof
/api/v2/federation/marketplace-linkAuthorization: BearerPlatform tokenDeep-link resolution
Common mistake: Sending a connection-level 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>.

7. Inbound Relay — POST /api/federation/relay

The recipient instance exposes this endpoint. It receives, validates, persists, and surfaces the relay to the operator.

7.1 Request headers

FieldTypeDescription
Content-Type*stringapplication/json
x-federation-token*stringPer-connection shared secret established during handshake.

7.2 Request body

FieldTypeDescription
connectionId*stringSender's local connection ID (echoed back for logging/diagnostics — NOT used for auth).
relayId*stringSender's local relay ID. Stored by the receiver as `peerRelayId` for correlation.
fromUserEmail*stringSender's email on the originating instance. Used for display + sender identity persistence.
fromUserNamestringSender's display name.
toUserEmail*stringRecipient's email on THIS instance. If no local user matches, receiver routes to the connection owner (fallback).
typestringSee §4.1. Default: `request`.
intentstringSee §4.2. Default: `custom`.
subject*stringOne-line human-readable summary (becomes card title, comms prefix, etc).
payloadobject | stringStructured data. Object OR JSON-encoded string (receiver normalizes).
prioritystring`urgent` | `normal` | `low`. Default: `normal` (or `low` if ambient).
dueDateISO8601Optional deadline.
threadIdstringShared thread root. See §11.
parentRelayIdstringSender's ID of the parent relay. Receiver maps this to its local parent via peerRelayId.
attachmentsarrayUp to 10 attachment objects. Can also be nested inside payload.attachments. See §12.
callbackUrlURLSender's `/api/federation/relay-ack` URL. Receiver uses this to push back completion acks.
teamIdstring(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.
projectIdstring(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.

7.3 Payload conventions

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.

FieldTypeDescription
_ambientbooleanIf true, relay is ambient mode (§10). Routes silently, surfaces via weaving not notification.
_contextstringConversational context that prompted the relay. Helps the receiving agent weave naturally.
_topicstringTopic tag. Used by recipient topic filters to opt out.
_instructionstringOptional directive for how the receiving agent should handle the relay.
_senderobjectReceiver 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.
attachmentsarrayAlternative location for attachment list (§12).

7.4 Response

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" }

7.5 Processing order (reference implementation)

  1. Validate x-federation-token against an active connection. 401/404 on failure.
  2. Check FederationConfig.allowInbound. 403 if disabled.
  3. Idempotency: if (peerRelayId, connectionId) already exists, return early with duplicate:true.
  4. Normalize payload (string → object), clamp attachments to 10.
  5. Populate payload._sender with sender identity.
  6. Detect ambient via payload._ambient or (intent="share_update" && priority="low").
  7. Resolve recipient (toUserEmail → local user; fallback to connection requester).
  8. If ambient, run gating (§10) — silently filter on block.
  9. Resolve thread + parent via peerRelayId lookup.
  10. Create AgentRelay row with status="delivered".
  11. If task intent (§13), create Kanban card in leads column.
  12. Post a CommsMessage (ambient: low priority auto-read; direct: new).
  13. Log activity.
  14. Respond 200 with receiver's relayId and threadId.

7.6 Scope resolution (v2.3.2)

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.

  1. If teamId is present, look up Team by ID on the local instance. Drop if not found.
  2. If projectId is present, look up Project. Drop if not found.
  3. If 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.
  4. Persist the resolved values on the new AgentRelay row (teamId, projectId).
  5. If the relay creates a KanbanCard, also persist projectId on the card (Kanban is project-scoped only — no teamId on card).
  6. Echo 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.

7.7 Ambient gating with scope (v2.3.2)

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.

8. Relay Ack — POST /api/federation/relay-ack

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.

8.1 Request body

FieldTypeDescription
relayId*stringThe 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.
localRelayIdstringThe receiver's own relay ID (informational, for debugging).
status*string`completed` | `declined` — terminal state.
responsePayloadstring | objectThe reply content. Can be string or JSON.
subjectstring(Optional) updated subject line for display.
timestampISO8601When the status changed on the receiver side.

8.2 Sender-side effects

On ack receipt, the sender instance:

  1. Updates relay status, responsePayload, resolvedAt.
  2. Logs federation_relay_completed activity.
  3. Posts a CommsMessage to the operator summarizing the outcome.
  4. If the relay was linked to a queue item, advances it to done_today.
  5. If a checklist item was linked, flips delegationStatus and completed.
  6. Emits a relay.state_changed webhook to local subscribers.
The receiver is responsible for pushing this ack — the sender never polls. If the receiver fails to push (network blip), the relay stays delivered on the sender side. The receiver SHOULD retry with exponential backoff.

9. Outbound (Sending a Relay)

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.

9.1 Pre-push checks (MANDATORY)

  1. Load the local relay row.
  2. If peerRelayId is already set, OR status is delivered|completed|declined, SKIP the push. This prevents duplicate delivery (§14).
  3. Verify the connection is isFederated=true, status="active", and has a federationToken.

9.2 Request

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"
}

9.3 Ack handling (on HTTP 200)

  1. Parse response JSON. Capture relayId as your peerRelayId.
  2. Update local relay: peerRelayId, peerInstanceUrl, status="delivered".
  3. Log federation_relay_acked activity.
  4. Post an optional low-priority comms confirmation to the sender operator.

9.4 Failure handling

  • HTTP 401 / 404: federation token invalid or connection inactive. Mark relay as expired or surface a reconnect prompt to the operator.
  • HTTP 403: inbound federation disabled on peer. Same treatment.
  • HTTP 5xx / timeout: retry with exponential backoff (suggested: 30s → 2m → 10m → 1h). Keep status as pending during retries.
  • Terminal retry cap: after 24h, transition to expired and notify the operator.

10. Ambient Protocol

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.

10.1 Identification

A relay is ambient if ANY of the following:

  • payload._ambient === true (preferred, explicit)
  • payload.ambient === true (legacy)
  • intent === "share_update" && priority === "low" (implicit)

10.2 Gating (receiver MUST implement)

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.

FieldTypeDescription
relayMode === 'off'gateReject: `relay_mode_off`
relayMode === 'minimal'gateReject: `relay_mode_minimal_blocks_ambient`
allowAmbientInbound === falsegateReject: `ambient_inbound_disabled`
relayTopicFilters contains payload._topicgateReject: `topic_filtered:{topic}`
now within relayQuietHoursgateReject: `quiet_hours`

10.3 Behavioral contract (RECEIVER AGENT)

The receiving instance's agent MUST follow these rules. DiviDen enforces them via the system prompt; external implementations should encode the equivalent behavior.

  1. HOLD silently. Do NOT surface the relay as a notification, banner, or standalone message.
  2. Weave on topic-match. When the operator's next message fuzzy-matches payload._topic or payload._context, weave the ambient content into your reply naturally.
  3. Attribute the source. Use sender name from payload._sender.name — e.g. "FVP mentioned earlier that the weather is 72°F...".
  4. No backend jargon. Never say "I received an ambient relay from...". Translate to conversational English.
  5. Respond silently. If the relay needs a reply, fire relay_respond without announcing "I sent a reply".
  6. Passive collection. If no topic match surfaces within 24h, the relay stays in the ambient queue until it expires or is dismissed.
  7. Never duplicate. Once woven or responded to, mark the relay resolved locally.

10.4 Ambient vs direct — UI surfacing

DiviDen's reference rendering:

FieldTypeDescription
Direct (purple)cardImmediately visible in comms feed. `new` state — beeps/banner. Purple card in ChatView.
Ambient ()cardAuto-marked `read` — silent. Appears in comms history but never triggers banner. Can be dismissed via ×.
Outgoing (green)cardSender's view of relays they pushed. Shows target, status, footnote with dismiss option.

11. Threading & Continuity

Relays form threads when the sender sets parentRelayId and/or threadId. Receivers MUST preserve thread continuity across instances.

11.1 IDs on each side

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.

11.2 Self-thread root

If no parent resolves and no threadId is supplied, the receiver treats the new relay as its own thread root — setting threadId = relay.id.

12. Attachments

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.

12.1 Schema

FieldTypeDescription
name*stringDisplay filename. Fallback chain: name → filename → 'attachment'.
url*stringPublicly accessible URL. Required — attachments without a URL are dropped.
sizeintegerBytes. Optional.
mimeTypestringMIME type. Aliases: mime, type.

12.2 Location

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.

12.3 Comms rendering

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)
Attachment URLs may expire (signed URLs). Sender SHOULD use long-lived URLs or refresh on a schedule. Receivers MAY cache the URL locally but MUST NOT re-host the binary without sender permission.

13. Task Intent → Kanban

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

13.1 Card fields

FieldTypeDescription
titlestringsubject
descriptionstringpayload.description || payload.body || payload.message || fallback
statusstring'leads' (intake)
prioritystringMapped from relay.priority: urgent→urgent, normal→medium, low→low
assigneestring'human' by default (the operator decides to delegate to agent)
dueDateISO8601From relay.dueDate
sourceRelayIdstringFK back to the relay (so card updates ack back)
orderintegerMax(order) + 1 in leads column — newest on top

13.2 Card updates back to sender

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"
}

14. Idempotency & Loop Prevention

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.

14.1 Receiver-side

// 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,
    });
  }
}

14.2 Sender-side

// 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
  },
});
Historical bug (pre-v2.3.0): without the 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.

15. Dismissal

Operators can dismiss a relay from either side. Dismissal is a local action with a federation ack-back.

15.1 Local endpoint

POST
/api/relays/{id}/dismiss

Auth: session. Terminates the relay locally and optionally pushes ack-back.

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

15.2 UI surface

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.

16. Sender Identity

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"
SENDERS MUST NOT set payload._sender. Receivers overwrite any value to prevent spoofing.

17. Recipient Preferences (UserProfile)

Every user has relay preferences on their UserProfile. These control gating (§10.2) and downstream surfacing behavior.

FieldTypeDescription
relayModeenum'full' | 'selective' | 'minimal' | 'off' — master switch. 'off' blocks ALL relays. 'minimal' blocks ambient.
allowAmbientInboundboolDefault true. Set false to reject ambient relays.
allowAmbientOutboundboolWhether this user's Divi is allowed to send ambient relays.
allowBroadcastsboolWhether this user receives broadcast relays.
allowAmbientSurveysboolOpt-in to the AmbientSurveys marketplace agent.
autoRespondAmbientboolDivi auto-answers ambient relays without surfacing to operator.
relayQuietHoursJSON{ start:"22:00", end:"08:00", timezone:"America/Chicago" } — suppresses non-urgent relays.
relayTopicFiltersJSON array["sales","recruiting"] — opt-out topic tags.
briefVisibilityenum'self' | 'connections' | 'public' — who can see reasoning briefs.
showBriefOnRelayboolAttach assembled brief to outbound relays so recipients can inspect reasoning.

18. Error Semantics

FieldTypeDescription
200 OKresponseRelay accepted. Check duplicate/filtered/fallback flags for routing outcome.
400 Bad RequestresponseMissing required field (connectionId, relayId, subject, etc). Body: { error }.
401 UnauthorizedresponseMissing x-federation-token header.
403 ForbiddenresponseInbound federation disabled (FederationConfig.allowInbound=false).
404 Not FoundresponseFederation token does not match any active connection.
429 Rate LimitedresponsePer-connection rate limit hit. Implementations SHOULD return Retry-After header.
500 Internal ErrorresponseUnexpected server error. Sender SHOULD retry with backoff.
Error body format: { error: "human readable message" } — no structured error codes yet. v2.4 will add an errorCode enum for stable programmatic handling.

19. Implementation Checklist

Before declaring your instance compliant with DAWP v2.3.0 relay protocol, verify each item below with integration tests.

Connection handshake (connect + accept callback) succeeds end-to-end
federationToken validated on every inbound — 401/404 on mismatch
POST /api/federation/relay returns 200 with receiver relayId on accept
Duplicate push (same peerRelayId) returns {duplicate:true}, no side effects
Sender skips push if local.peerRelayId already set OR status in [delivered, completed, declined]
Sender stamps status="delivered" on ack (loop prevention)
Receiver populates payload._sender with {name,email,instanceUrl,connectionId,isFederated}
Ambient gating: relayMode, allowAmbientInbound, topic filters, quiet hours all enforced
Ambient gate rejection returns 200 {ok:true, filtered:true, reason:...}, no record created
Agent HOLDS ambient silently, weaves only on topic match
Agent never announces &quot;I received a relay&quot; — weaves in natural language
Task intents (assign_task, delegate, schedule, request_approval) create Kanban card in intake column
Card fields: sourceRelayId, title, description, priority mapping, order=max+1
Threading: parentRelayId resolved via peerRelayId lookup; threadId echoed in response
Attachments clamped to 10, accepted in body OR payload.attachments, URL required
relay-ack POST updates sender status, resolvedAt, responsePayload
relay-ack advances linked queueItem → done_today
relay-ack updates linked checklistItem.delegationStatus + completed
Dismiss endpoint POST /api/relays/{id}/dismiss works for both sides
Dismiss pushes ack-back to federated peer with status=declined
Footnote displays sender (handle) · direct|ambient · relative time · status · dismiss
Failures: 5xx triggers exponential backoff retry (30s→2m→10m→1h, expire after 24h)
Rate limit: 120 req/min per federation token, returns 429 with Retry-After
webhook relay.state_changed emitted on every status transition

20. Versioning & Compatibility

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.

FieldTypeDescription
v2.0releaseBaseline: request/response/notification, threads, peerRelayId idempotency.
v2.1releaseAmbient protocol, topic filters, quiet hours, card intent auto-create.
v2.2releaseAttachments (max 10), threadId echo, flexible intents (ask, opinion, note, intro).
v2.3releaseLoop prevention (idempotency guard + status stamp), RelayFootnote, dismiss endpoint, sender resolution, 7-rule ambient HOLD/weave system prompt.
v2.3.1releaseProject 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.2releaseMulti-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).

Compatibility rules

  • Senders MAY send new optional fields. Receivers MUST ignore unknown fields (no strict-parse).
  • Unknown intents are treated as custom.
  • Unknown statuses on ack are logged but do not fail the request.
  • Breaking changes bump the major version; DiviDen will advertise both old and new endpoints in parallel for 90 days.
Minimum compliance to interop with dividen.ai today: connection handshake, inbound/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.

Download a plain-text copy of this page

Last updated: May 28, 2026