Implementation recipe for the v2.3.1 project invite flow — invite, accept, decline, reinvite, and federated delivery.
pushRelayToFederatedInstance AND pushNotificationToFederatedInstance (type=project_invite, withprojectId top-level) after records are written. The v2.3.1 gap where federated invitees received the local records but no cross-instance notification is now closed. Relay stays pending until peer ACKs via /api/federation/relay-ack. See relay-spec §7.6 for scope resolution semantics on the receiver.Contents
Before v2.3.1, a project invite was a silent row-insert into ProjectInvite plus a generic notification QueueItem. It didn't exist in the operator's bell count, it didn't show up in Divi's context, it didn't thread with other Divi-to-Divi comms, and nobody could see who was invited without drilling into the card detail.
In v2.3.1 the invite is a relay first, with four records written in one transaction:
ProjectInvite — source-of-truth for invite stateQueueItem — invitee's queue surfaces the actionAgentRelay (intent='introduce', payload.kind='project_invite') — so the invite is logged on both sides' Comms tab and picked up by federation pushCommsMessage (sender='divi') — so the invitee's inbox, bell count, and Divi conversation naturally pick it upThis is the template every important action should follow: mutate state → queue the action → log it as a relay → surface it as a message. One code path, four signals.
┌─────────────────┐ POST /api/projects/:id/invite ┌─────────────────┐
│ Inviter UI │───────────────────────────────────────▶│ API route │
│ (CardDetail) │ { connectionId, role, message, force? }│ (server) │
└─────────────────┘ └────────┬────────┘
│
┌────────────────────────────────────────────────┼────────────────────┐
│ │ │
┌───────▼──────┐ ┌──────────┐ ┌────────────┐ │ ┌─────────────────┐
│ ProjectInvite│ │QueueItem │ │ AgentRelay │ │ │ CommsMessage │
│ (creates) │ │(invitee) │ │ (introduce │ │ │ (sender=divi, │
│ │ │notification│ │ kind=PI) │ │ │ to=invitee) │
└──────────────┘ └──────────┘ └──────┬─────┘ │ └─────────────────┘
│ │
federation check on connection │
│ │
┌──────▼───────┐ │
│ local target │ │
│ ───── or ─── │ │
│ federated: │ │
│ push relay │ │
│ to peer URL │ │
└──────┬───────┘ │
│ │
┌───────────────────────────────────────────────▼─────────▼────────────┐
│ Invitee's Divi instance │
│ Queue + Inbox + Bell + Card ghost avatar + Comms thread + Accept/Dec │
└──────────────────────────────────────────────────────────────────────┘Authenticated call to POST /api/projects/[id]/invite. Body fields:
connectionId? — invite by Connection record (preferred for federation)userId? — invite by local user ID (same-instance)email? — invite by email lookuprole? — 'lead' | 'contributor' | 'observer' (default 'contributor')message? — optional note from inviter; surfaces on the invitee's queue cardforce? — if true, rotate an existing pending invite instead of erroring// Example: invite from client
const res = await fetch(`/api/projects/${projectId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connectionId: 'conn_abc',
role: 'contributor',
message: 'Want your eye on the Q3 board',
}),
});
if (res.status === 409) {
const { code, inviteId } = await res.json();
// code === 'ALREADY_INVITED'
// surface the 'Resend invite / Keep existing' prompt to the user
// to resend: POST again with { ...originalBody, force: true }
} else if (res.ok) {
const { invite, relayId, replacedInviteId } = await res.json();
window.dispatchEvent(new CustomEvent('dividen:board-refresh'));
window.dispatchEvent(new CustomEvent('dividen:comms-refresh'));
}The endpoint writes up to four records in a Prisma transaction:
{
id: string, // cmp_*
projectId: string,
invitedByUserId: string, // inviter
invitedUserId: string|null,
invitedEmail: string|null,
connectionId: string|null, // for federated invites
role: 'lead'|'contributor'|'observer',
message: string|null,
status: 'pending'|'accepted'|'declined'|'cancelled',
createdAt, updatedAt
}{
userId: invitee.id,
type: 'notification',
status: 'pending',
title: 'Project invite: <projectName>',
body: '<inviterName> invited you to join as <role>.',
metadata: {
type: 'project_invite', // <-- discriminator for QueuePanel
inviteId: <ProjectInvite.id>,
projectId, projectName, role, inviterName, message
},
priority: 'high'
}{
type: 'request',
intent: 'introduce',
direction: 'outbound', // from inviter's side
status: 'delivered',
connectionId: <invitee connection id, if federated>,
fromUserId: inviter.id,
toUserId: invitee.id,
subject: 'Invite to "<projectName>"',
payload: JSON.stringify({
kind: 'project_invite',
inviteId, projectId, projectName,
role, message, inviterName
}),
priority: 'high',
visibility: 'both'
}{
userId: invitee.id,
peerId: inviter.id,
sender: 'divi', // surfaces in invitee's bell + comms thread
direction: 'inbound',
content: '<inviterName> invited you to "<projectName>" as <role>.',
contentType: 'project_invite',
relatedRelayId: <AgentRelay.id>,
metadata: { inviteId, projectId, projectName, role }
}If the target connection has isFederated=true and a peerInstanceUrl, the invite route fires two async pushes (v2.3.2): pushRelayToFederatedInstance(relayId) andpushNotificationToFederatedInstance({type:'project_invite', projectId, ...}). Both are fire-and-forget POSTs to the peer's /api/federation/relay and /api/federation/notifications with the x-federation-token header. 10-second timeout; failure leaves the relay in place locally for the peer to pick up on next poll.
// Inside the invite route, after records are written:
if (invitee.connection?.isFederated && invitee.connection.peerInstanceUrl) {
// Runs async, never blocks the response
pushRelayToFederatedInstance(relay.id).catch(err =>
console.error('[invite] federation push failed', err)
);
pushNotificationToFederatedInstance({
connectionId: invitee.connection.id,
type: 'project_invite',
title: `Project invite: ${project.name}`,
message: `${inviter.name} invited you to join ${project.name}`,
projectId: project.id,
teamId: project.teamId ?? undefined,
metadata: { inviteId: invite.id, inviterUserId: inviter.id },
}).catch(err => console.error('[invite] notification push failed', err));
}The federation push is idempotent (v2.3): if peerRelayId is already stamped, it skips re-pushing, preventing the duplicate-delivery cascade that used to happen on retries.
Scope wire fields (v2.3.2): both endpoints accept top-level teamId and projectId. The receiver runs scope resolution — if the IDs exist locally, they're attached to the mirrored records; if not, they're dropped and echoed back as scopeDropped in the ack response. See relay-spec §7.6.
On the receiving instance, POST /api/federation/relay ingests the relay and, becausepayload.kind === 'project_invite', the handler mirrors the same record set locally:
ProjectInvite stub (or syncs state if one already exists for the connection)QueueItem with metadata.type='project_invite'CommsMessage so the bell count ticks + the Comms tab threads it under the inviterpayload._sender with {name,email,instanceUrl,connectionId,isFederated:true} so the agent can resolve sender identity without a local user rowPATCH /api/project-invites (or equivalent) so the receiver can Accept/Decline. Full parity (queue + comms + card) is strongly recommended but not strictly required — senders degrade gracefully if ack is received.// From QueuePanel or inbox row:
const res = await fetch('/api/project-invites', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteId, action: 'accept' }), // or 'decline'
});
if (res.ok) {
window.dispatchEvent(new CustomEvent('dividen:board-refresh'));
window.dispatchEvent(new CustomEvent('dividen:queue-refresh'));
window.dispatchEvent(new CustomEvent('dividen:comms-refresh'));
}Server-side effects per action:
ProjectMember ({projectId, userId, role})ProjectInvite.status → 'accepted'QueueItem.status → 'done_today'AgentRelay.status → 'completed'CommsMessage marked read/api/federation/relay-ackProjectInvite.status → 'declined'QueueItem.status → 'cancelled'AgentRelay.status → 'declined'status=declinedThe endpoint looks up any existing pending invite for (projectId, invitee)before writing. If one exists and the request does not include force:true, it returns:
HTTP 409
{
"error": "User already has a pending invite for this project",
"code": "ALREADY_INVITED",
"inviteId": "cmp_xyz"
}The UI uses this to surface an inline prompt — "already has a pending invite" with Resend inviteand Keep existing buttons. Resend repeats the original POST with force: true added:
// Server behavior when force:true
// 1. Cancel the existing invite and its side-effect records
// - ProjectInvite.status → 'cancelled'
// - QueueItem.status → 'cancelled' (via metadata.inviteId match)
// - AgentRelay.status → 'cancelled'
// - CommsMessage.hiddenAt = now
// 2. Create a fresh ProjectInvite + QueueItem + AgentRelay + CommsMessage
// 3. Respond with:
{
"success": true,
"invite": { /* new invite */ },
"relayId": "clx_new",
"replacedInviteId": "cmp_xyz",
"message": "Invite resent."
}replacedInviteId link for auditability.Pinned "Pending Invites" section at top. Each invite renders an inline card with Accept / Decline buttons.
Unread CommsMessage (sender=divi, contentType=project_invite) increments the bell.
The CommsMessage threads under the inviter. Shows relay footnote with type + status + dismiss.
Kanban card shows pending invites as dashed amber avatars alongside active contributors.
Card detail modal's Contributors section opens expanded by default. Lists active members and pending invites with status dots.
Inside Contributors section. Search-as-you-type against accepted Connection records. Duplicate guard + force reinvite inline.
Every state change in the invite lifecycle should dispatch the relevant refresh events so open views update without a full reload:
dividen:board-refresh — every kanban board view listens; refetches projects + cards.dividen:queue-refresh — QueuePanel listens; refetches queue items.dividen:comms-refresh — Comms tab + bell listen; refetches threads and unread count.dividen:notifications-refresh — NotificationsPopover listens; refetches notification feed.// Dispatch all three after any mutate:
function refreshInviteSurfaces() {
const events = ['board-refresh', 'queue-refresh', 'comms-refresh', 'notifications-refresh'];
events.forEach(name => window.dispatchEvent(new CustomEvent(`dividen:${name}`)));
}SELECT pi.*, u.name AS inviter_name
FROM "ProjectInvite" pi
JOIN "User" u ON u.id = pi."invitedByUserId"
WHERE pi."projectId" = $1
AND pi.status = 'pending'
ORDER BY pi."createdAt" DESC;SELECT
pi.id AS invite_id, pi.status AS invite_status,
ar.id AS relay_id, ar.status AS relay_status, ar."peerRelayId",
cm.id AS comms_id, cm."hiddenAt" AS comms_hidden
FROM "ProjectInvite" pi
LEFT JOIN "AgentRelay" ar
ON ar.payload::jsonb->>'inviteId' = pi.id
LEFT JOIN "CommsMessage" cm
ON cm.metadata::jsonb->>'inviteId' = pi.id
WHERE pi.id = $1;SELECT q.id, q.title, q.status, q.metadata
FROM "QueueItem" q
JOIN "ProjectInvite" pi ON pi.id = q.metadata::jsonb->>'inviteId'
WHERE q.metadata::jsonb->>'type' = 'project_invite'
AND pi.status IN ('cancelled','declined')
AND q.status = 'pending';The invite flow is the prototype for every important action that should feel like a communication event — not a silent database write. Apply the same four-record pattern whenever you add:
The pattern is deliberately uniform: pick a relay intent, choose a payload.kind discriminator, write the four records in a transaction, push via federation if the target is federated, dispatch refresh events client-side.
Download a plain-text copy of this page
Last updated: May 28, 2026
Built by DiviDen — the Agentic Working Protocol