Project Invites Integration Guide

Implementation recipe for the v2.3.1 project invite flow — invite, accept, decline, reinvite, and federated delivery.

Scope. This guide is the canonical reference for anyone wiring up project invites — whether inside the DiviDen monorepo, on a federated peer instance (FVP, a self-hosted node, an agent bridge), or in a third-party integration that needs to react to invite events.
v2.3.2 update: Federated project invites now correctly push over the wire. The invite route fires both 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.

1. Why we changed this

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 state
  • QueueItem — invitee's queue surfaces the action
  • AgentRelay (intent='introduce', payload.kind='project_invite') — so the invite is logged on both sides' Comms tab and picked up by federation push
  • CommsMessage (sender='divi') — so the invitee's inbox, bell count, and Divi conversation naturally pick it up

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

2. End-to-end flow

┌─────────────────┐     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 │
           └──────────────────────────────────────────────────────────────────────┘

3. Sending an invite

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 lookup
  • role? — 'lead' | 'contributor' | 'observer' (default 'contributor')
  • message? — optional note from inviter; surfaces on the invitee's queue card
  • force? — 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'));
}

4. Records created on send

The endpoint writes up to four records in a Prisma transaction:

4.1 ProjectInvite

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

4.2 QueueItem

{
  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'
}

4.3 AgentRelay

{
  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'
}

4.4 CommsMessage

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

5. Federation / cross-instance delivery

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.

6. Receiving & surfacing on the peer

On the receiving instance, POST /api/federation/relay ingests the relay and, becausepayload.kind === 'project_invite', the handler mirrors the same record set locally:

  • Creates a local ProjectInvite stub (or syncs state if one already exists for the connection)
  • Creates a QueueItem with metadata.type='project_invite'
  • Creates a CommsMessage so the bell count ticks + the Comms tab threads it under the inviter
  • Populates payload._sender with {name,email,instanceUrl,connectionId,isFederated:true} so the agent can resolve sender identity without a local user row
Minimal compliance for peer integrators: at minimum treat the relay as an actionable notification and expose PATCH /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.

7. Accept / Decline wiring

// 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:

Accept
  • Create ProjectMember ({projectId, userId, role})
  • ProjectInvite.status → 'accepted'
  • QueueItem.status → 'done_today'
  • AgentRelay.status → 'completed'
  • CommsMessage marked read
  • If federated, ack-back via /api/federation/relay-ack
Decline
  • ProjectInvite.status → 'declined'
  • QueueItem.status → 'cancelled'
  • AgentRelay.status → 'declined'
  • Thread marked resolved on both sides
  • If federated, ack-back with status=declined

8. Duplicate guard & force reinvite

The 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."
}
Why not just PATCH the old invite? Because the queue item and comms message carry the original timestamp and inviter context. Replacing the record set keeps the invitee's surfaces fresh (new bell ping, new queue entry) while preserving the replacedInviteId link for auditability.

9. UI surfaces (6 of them)

QueuePanel

Pinned "Pending Invites" section at top. Each invite renders an inline card with Accept / Decline buttons.

Bell count

Unread CommsMessage (sender=divi, contentType=project_invite) increments the bell.

Comms thread

The CommsMessage threads under the inviter. Shows relay footnote with type + status + dismiss.

Card ghost avatar

Kanban card shows pending invites as dashed amber avatars alongside active contributors.

‍‍Contributors section

Card detail modal's Contributors section opens expanded by default. Lists active members and pending invites with status dots.

Add Contributor picker

Inside Contributors section. Search-as-you-type against accepted Connection records. Duplicate guard + force reinvite inline.

10. Client-side refresh events

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}`)));
}

11. Useful SQL queries

Pending invites for a project

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;

Invite relay + comms for audit trail

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;

Find orphaned queue items (invite cancelled but queue entry not)

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';

12. Extending the pattern

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:

  • Role changes — promote / demote / remove a contributor should emit an AgentRelay + CommsMessage to the affected user.
  • Shared-context handoffs — when an operator hands a thread to a teammate, the receiving side should see a queue item + comms bubble.
  • Team membership events — team invites, team promotions, team disbands.
  • Federated capability updates — when a peer publishes a new capability, push a relay so the connected operator sees it surface naturally.

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.

13. Integration checklist

  • Server: duplicate guard checks ProjectInvite.status=pending before insert
  • Server: 409 { code: "ALREADY_INVITED", inviteId } when duplicate + !force
  • Server: force:true cancels existing ProjectInvite + QueueItem + AgentRelay + CommsMessage
  • Server: all 4 records created in a single Prisma transaction
  • Server: AgentRelay.intent="introduce" + payload.kind="project_invite"
  • Server: CommsMessage.sender="divi" + contentType="project_invite"
  • Server: QueueItem.metadata.type="project_invite" (discriminator)
  • Server: federation push fired async when connection.isFederated
  • Server: federation ingest mirrors records on peer + ack-back
  • Client: POST returns relayId, inviteId, replacedInviteId?
  • Client: 409 prompts inline "Resend invite / Keep existing"
  • Client: Accept/Decline wired to PATCH /api/project-invites
  • Client: dispatches dividen:board-refresh + queue-refresh + comms-refresh
  • UI: ghost avatars for pending invites on kanban cards
  • UI: Contributors section opens expanded by default inside card detail
  • UI: QueuePanel pinned "Pending Invites" section with Accept/Decline

Download a plain-text copy of this page

Last updated: May 28, 2026