Skip to content
GitHub
Agents

Spectral Agent Chat Privacy

This page describes the privacy model for Spectral Agent conversations only. The three broadcast conversation states and the fork-on-reply mechanism apply to Spectral Agent customer-facing conversations; the World Agent and Operations Agent have separate conversation models documented in their own pages.

Spectral Agent conversations have three distinct privacy states. The state of a conversations row is determined by the combination of initiated_by, user_id, forked_from, and broadcast_key, and is enforced both at the application layer (repo + router) and at the database layer (RLS policies + partial unique indexes).

initiated_by = 'customer'
user_id = <owner>
forked_from = NULL
broadcast_key = NULL

Visible and writable only to user_id. Acts like a ChatGPT session — other workspace members cannot list, read, or write to these conversations. This is the default shape for anything created via POST /api/v1/workspaces/{id}/agent/chat without an explicit conversation_id.

initiated_by in ('agent','system')
user_id IS NULL
forked_from IS NULL
broadcast_key = '<stable-key>'

A broadcast template is a read-only conversation starter visible to every member of the owning workspace. The broadcast generator upserts these rows by (workspace_id, broadcast_key) so re-running it is idempotent — the generator never creates duplicates and never overwrites messages on existing rows.

Workspace members see broadcasts in their conversation list alongside their own private chats. They cannot write to the broadcast directly; any attempt to reply triggers the fork-on-reply flow below.

initiated_by in ('agent','system')
user_id = <replying user>
forked_from = <broadcast id>
broadcast_key = NULL

When a user first replies to a broadcast template, the server creates a new conversations row owned by that user, copies the broadcast’s messages as seed history, and appends the user’s first message. The original broadcast remains intact for other workspace members. Each user who engages with the same broadcast gets their own distinct fork; concurrent first-replies from the same user are collapsed to a single fork via a partial unique index on (forked_from, user_id).

All routes live under /api/v1/workspaces/{workspace_id}/agent/ and require read:workspace.

Sends a user message to the agent. Behavior depends on the target:

TargetResult
No conversation_idCreates or resumes the caller’s own private conversation
conversation_id owned by callerAppends the message to the existing conversation
conversation_id is a broadcast templateForks into a new per-user branch and returns the fork id
conversation_id owned by another user404 (existence is not leaked)

GET /api/v1/workspaces/{id}/agent/conversations

Section titled “GET /api/v1/workspaces/{id}/agent/conversations”

Lists conversations visible to the caller:

  • All of the caller’s own private conversations (customer chats and forked branches they’ve engaged with).
  • All un-engaged broadcast templates in the workspace.

Never includes another member’s private conversations — even workspace admins must use a different surface to audit per-user chats.

GET /api/v1/workspaces/{id}/agent/conversations/{conversation_id}/messages

Section titled “GET /api/v1/workspaces/{id}/agent/conversations/{conversation_id}/messages”

Returns 404 if the caller does not own the conversation and it is not a broadcast template. The router enforces this with a pre-check against get_conversation(conv_id, workspace_id, user_id) before ever issuing the messages query, so a wrong-user probe cannot confirm whether a conversation id exists.

The conversations table carries check constraints that prevent invalid state combinations:

  • conversations_customer_has_user — customer-initiated rows must have a non-null user_id.
  • conversations_broadcast_has_no_user — rows with broadcast_key set must have user_id IS NULL and initiated_by in ('agent','system').
  • conversations_fork_has_user — forked rows must carry both forked_from and user_id.

RLS policies enforce per-user visibility:

  • conversations_select — members see their own conversations plus un-engaged broadcasts in their workspace. Workspace admins and account owners retain full read visibility for moderation.
  • conversations_insert_customer_or_fork — authenticated users can only insert rows where user_id = auth.uid(). Broadcast creation (user_id IS NULL) requires the service role — the application layer creates broadcasts via AsyncSupabaseAgentConversationRepository.create_broadcast, which runs inside a worker/service-role connection.

The is_conversation_member(conv_id) helper used by the conversation_messages policies extends to broadcasts — any workspace member is treated as a member of every un-engaged broadcast in that workspace, so the per-message policies continue to work uniformly for both private and broadcast states.

Broadcasts are appropriate when Spectral wants to push the same conversation starter to every workspace member — for example:

  • Daily/weekly digests of scan results.
  • Notifications about completed change sets requiring review.
  • System announcements scoped to a single workspace.

If the content differs per-user (e.g. “your scan completed”), create per-user conversations directly rather than a broadcast. Broadcasts are a sharing mechanism, not a templating engine.

  • Operations App overview — the next cluster in the spine. The Operations app hosts the operator-side surface, where authoring, distillation, and publication workflows live alongside the Operations Agent.
  • Agent Architecture — the three-agent topology this page sits inside; this page is the Spectral-Agent-only privacy specification.
  • Access Control — the RLS doctrine the per-user conversation policies on this page extend.
  • Notifications — channel adapters that interact with conversations (the in-app InAppAdapter opens/extends conversations on broadcast push).