Skip to main content

Conversations

Endpoints for the chat surface beyond the basic message list: conversation search, emoji reactions on assistant messages, group-chat participants, mid-turn steering, file attachments uploaded for an agent, and the workspace rooms — human DMs, private groups and channels — that live on /api/v1/conversations. See the Chat & Sessions guide for the user-facing model. Migration v57 (add_chat_extras) added the message_reactions, chat_attachments, chat_branches, and workspace_files tables that back this surface.
Endpoints require an authenticated session, except the shared-transcript reader, which requires its own dedicated share bearer.

Endpoints

The room routes are documented under Workspace rooms; the CLI for them is crewship chat room. Peer-to-peer crew messaging lives on the internal IPC plane, not the public API.

Search conversations

Searches indexed chat messages using BM25 relevance, in one of two scopes:
  • agent scope — agent_id given. The handler verifies that agent belongs to the caller’s workspace before searching it.
  • workspace scope — agent_id omitted. The handler reads the caller’s own agents (from the workspace on the request context, never from the body) and runs ONE ranked query across them. This is what ⌘K sends: the user is searching everything they can see and has no agent in mind to name.
In both scopes the search backend is a filter, not an authorization boundary — the handler decides which agent ids it may ever see, and drops any hit that comes back for an agent outside that set. Workspace scope spans at most 400 agents (the most recently created ones); the bound exists because the agent ids are bound query parameters. Auth: authenticated session or CLI token with workspace context.

Request

query is required. agent_id is optional — omitting it selects the workspace scope. limit is optional; the search store defaults or clamps it to its supported range (the CLI default is 20 and its maximum is 100).

Response

scope is agent or workspace. agent_slug and agent_name are resolved by the handler (the search mirror stores only an agent id) so a caller can say who said it and link to the thread at /chat/<agent_slug>?session=<session_id> — the same URL chat notifications deep-link to.

Message reactions

Reactions are scoped per (chat, message, emoji, user) — UNIQUE(chat_id, message_id, emoji, user_id) — so a user cannot double-react with the same emoji. The List endpoint returns aggregated counts, not a per-user list. Each row carries the emoji, the total count from any user, and mine (a boolean for the authenticated caller). This lets the FE render 👍 3 (you) without a second join.

List reactions

Path parameters: chatId is the chat ID; messageId is the message ID. Auth: An authenticated workspace member. Chat tenancy is checked from the chat’s workspace and the caller’s membership. Response: 200 OK
Sorted by count descending then emoji ascending. Workspace tenancy is enforced inside the handler — the route does not run through the wsCtx middleware because chatId is the only path parameter; the handler joins chats.workspace_id → workspace_members.role to confirm the calling user has access. Errors:

Add reaction

Request body:
Response: 204 No Content — no body. Idempotent: posting the same (user, message, emoji) twice succeeds the second time without creating a duplicate row, thanks to the UNIQUE constraint. Errors: 400 invalid emoji or empty body; 401 not authenticated; 404 chat not in workspace.

Remove reaction

Path parameters: chatId is the chat ID; messageId is the message ID; emoji is the reaction to remove. Auth: An authenticated workspace member. Chat tenancy is checked from the chat’s workspace and the caller’s membership. The emoji is a path segment, not a query parameter. URL-encode if it contains characters that need escaping (👍 is multi-byte UTF-8 and works in modern HTTP clients without further encoding, but a safe-belt encodeURIComponent doesn’t hurt). Response: 204 No Content on success. Idempotent: removing a non-existent reaction returns 204, not 404. Errors: 401 not authenticated; 404 chat not found or caller is not a workspace member; 500 database error while removing the reaction.

Chat participants

The humans in a multi-user group chat. Adding the first extra participant promotes the chat to visibility='group', after which the agent only responds when @mentioned rather than to every message — see the Group chats on an agent session section of the Chat & Sessions guide for the user-facing model. CLI parity: crewship chat participants. Scoped via chats.workspace_id, with no workspace_id in the path or body — the handler resolves the chat’s workspace and checks the caller’s membership itself. A cross-tenant or unknown chatId is 404 for every verb below. Managing (POST/DELETE) requires the chat’s creator or a workspace OWNER/ADMIN — a plain member can list participants but not mutate the roster, otherwise any member could reshape any chat in the workspace. A participant whose per-row role is owner is not thereby granted this right; role is an informational label only (see Add participant).

List participants

Auth: Any authenticated workspace member with access to the chat. Response: 200 OK
participants is [] for a private (never-promoted) chat, not an error. Errors: 404 unknown or cross-workspace chat, or caller is not a member.

Add participant

Request body:
The chat’s creator is seeded as an owner participant row on the first POST if it isn’t already present, so a freshly promoted group lists everyone rather than leaving the creator implicit. The chat is promoted to visibility='group' in the same transaction. Response: 204 No Content. Errors:

Remove participant

Path parameters: chatId; userId — the participant to remove. Response: 204 No Content. Idempotent-adjacent: removing a user who isn’t currently a participant still returns 204 (the DELETE has no existence check beyond the owner guard below). Errors:

Mid-turn steering

Redirect an agent that is already mid-turn — “actually, focus on the auth bug first” — without waiting for the current run to finish or racing a second run into the same container. See Mid-turn steering in the Chat & Sessions guide for the full behavioral model. CLI parity: crewship chat steer.

Steer a chat

Auth: Any authenticated caller allowed to operate the chat (workspace membership via chats.workspace_id, checked in-handler — same tenancy contract as the reactions endpoints). Request body:
Response: 202 Accepted
queued is true once the message is durably queued for the agent’s next turn. in_flight reports whether a run was already live for the chat when the request landed — true means the steer lands after the current turn finishes; false means it lands at the start of the next turn you kick off. This slice is queue-for-next-turn, not live injection: interrupting a generation already in progress is a planned follow-up, not implemented here. Errors:

Chat attachments

Files attached by an operator to a chat session. The binary is written to the storage provider and the agent’s container sees the file under /output/<agentSlug>/attachments/<chatId>/<attachmentId>/<filename>. Each upload also records a row in attachments (owner_type = 'chat') carrying the filename, resolved content type, size, SHA-256 and uploader. The row is written before the bytes and promoted after they land, so a 201 always means both exist — a success can never mean “the file is stored and nothing recorded it”. An upload that fails part-way leaves an unpublished row, which is never listed and is reclaimed with any bytes it named within about two hours (a one-hour grace period, collected by an hourly sweep). Before #1768 no row was written at all and the file on disk was the entire state; the dead chat_attachments table it was supposed to use was dropped in the same change.
The blob is not content-addressed the way an issue attachment’s is: it stays at <crewID>/<agentSlug>/attachments/<chatId>/<attachmentId>/<filename> because that path is the agent-visible contract. storage_key on the row is the authority on where any given attachment lives — which is what keeps attachments uploaded before the id segment existed working at their original path. The attachment id in the path is what makes one upload one identity at one location: two different files called evidence.pdf are two attachments with two checksums, and neither overwrites the other. Listing and deletion live on the agent route — see List Chat Attachments and Delete Chat Attachment. There is still no download endpoint for a chat attachment; the agent reads it from its own filesystem.

Upload attachment

The path is agent-and-chat scoped, not just chat-scoped — the handler verifies the chat belongs to the agent so a stray chatId cannot land files in another agent’s namespace. The role check requires the create permission (OWNER, ADMIN, MANAGER); MEMBER and VIEWER are 403. Form fields: Cap: 25 MB per upload. Multipart spill files are deferred-cleaned to keep os.TempDir() from filling under repeated uploads. Response: 201 Created
The path is relative — the agent reads it from /output/<agentSlug>/<path>. agent_path is the same location pre-resolved to its absolute in-container path, handy for the agent prompt. Errors:

Run stream

Watching an agent run used to require a WebSocket client: mint a short-lived JWT at GET /api/v1/ws-token, upgrade /ws, subscribe to the session:{chatId} channel. That is right for the browser and wrong for a script or an agent working from a shell. This endpoint streams the same frames over ordinary authenticated HTTP as newline-delimited JSON, so curl -N is a sufficient client. It is additive. The WebSocket path is unchanged and the dashboard still uses it. Both read one event source and one replay buffer, so ordering, sequence numbers and access control cannot drift apart between them. The user-facing walkthrough is Watching agent runs.

Stream a run

Path parameters: chatId is the chat (session) ID. Query parameters: Auth: An authenticated caller who may subscribe to the chat’s session:{chatId} channel — the same check the WebSocket subscribe and resume paths run. Tenancy comes from the chat’s own workspace membership; this route takes no workspace context parameter and does not read X-Workspace-ID. Response: 200 OK, Content-Type: application/x-ndjson, one JSON object per line, flushed as it is produced.
Two families of frame share the type field:
  • Agent events carry the event name verbatim: run_begin, text, thinking, tool_call, tool_result, status, error, done. These are the same events the WebSocket session channel emits, with the {"type":"chat_event","payload":{…}} envelope flattened away — a shell pipeline should not have to know about the transport’s envelope.
  • Control frames are namespaced with a stream. prefix. No agent event name contains a dot, so the two are always distinguishable.
Authorization is continuous. The caller is re-checked against the chat’s workspace membership on the hub’s periodic sweep (about every 30 s), not only at request time. A caller removed from the workspace mid-stream is detached and the response ends with reason: access_revoked — the same enforcement a WebSocket subscriber on that channel gets. Which runs emit frames. Every run that has a chat publishes here: chat messages, crewship run / crewship ask, routine agent_run steps, webhook triggers, scheduled (cron’d) agents and the internal agent-start IPC. Two cases do not, by design: a run executed with no chat row (there is no session: channel to publish on), and a delegated or peer sub-agent, whose events belong to the delegating agent’s turn rather than to a turn of its own. Sequence numbers and resume. Every run frame carries a seq that is monotonic per session and survives run boundaries. Reconnect with ?last_seq=<n> and the server replays the buffered gap before resuming live delivery. Replay is only offered while the run is still active: a finished run is persisted, so its transcript comes from GET /api/v1/chats/{chatId}/messages instead — replaying it here as well would double it. If the buffer overflowed (5000 frames or 8 MiB in one run), the server answers stream.reset with reason: replay_truncated rather than serving a partial stream that would render as complete. Errors: CLI parity: crewship chat stream.

Crew messaging (sidecar IPC)

Peer-to-peer messages between agents in the same crew live on the internal IPC plane, not the public API. There is no equivalent public surface — operators see crew messages via the Crew Journal (entry types peer.conversation and peer.escalation). See Internal IPC API — Crew messaging and files for the full request/response shape.

Tenancy

  • Conversation search: the scope comes from the request context. With agent_id, cross-workspace returns 404; without it, another workspace’s caller searches only its own agents and simply gets no hits.
  • Reactions: handler joins chats.workspace_id → workspace_members.role. No workspace_id in path or body. Cross-workspace returns 404.
  • Participants: same chats.workspace_id join as reactions; mutating additionally requires being the chat creator or a workspace OWNER/ADMIN.
  • Steering: same chats.workspace_id join; any workspace member with chat access may steer.
  • Attachments: agent-and-chat both validated against the session’s workspace. Cross-workspace returns 404; chat-not-on-agent returns 403.
  • Crew messaging is internal-only; the sidecar’s IPCConfig carries the scope.

Continue a private conversation in a new room

POST /api/v1/conversations/{conversationId}/continue creates a separate room with fresh history. It never changes the source’s privacy or copies its messages. Either current participant of a DM can start a new private group; a member of a private group or DM can explicitly start a workspace-visible channel with an agent.
kind=group requires a DM source and at least one additional workspace human; both original participants are included. It rejects agent_id. For a new workspace-visible channel, use kind=channel and a required agent_id; optional member_ids adds humans to the existing roster. Creating and joining the agent commit atomically; joining does not run the agent. Send a new structured mention to request an answer. Private history remains accessible only in the source. title is 1–120 characters, member_ids accepts at most 498 entries, and client_id is a required opaque retry ID of at most 128 UTF-8 bytes. Reuse that ID for the same request after a lost response: the server returns the existing new room (200); first creation returns 201. Reusing it with different content returns 409. The retry identity is scoped to the source and requesting human. Unauthorized source access or foreign-workspace invitees return 404. Group creation starts empty. Channel creation contains only the new agent join notice, not prior private messages. Existing direct-pair reuse is unchanged.

Workspace rooms

These rooms back Chat’s human DMs, private groups and workspace channels. Their IDs and message sequences are separate from legacy agent chats sessions above. A DM is a group with is_direct=true and a fixed pair of humans. A channel is visible to current workspace members; removing someone from its explicit roster does not revoke workspace visibility.

Workspace room authentication and error contract

Auth: every room request requires an authenticated user and workspace context (for example ?workspace_id=WORKSPACE_ID). Private groups and DMs additionally require conversation membership. The server checks access on each request; authentication alone never grants access to a private room. Request: path IDs identify resources within that workspace. JSON bodies reject unknown fields and trailing JSON. Do not send author identity or trusted message source_kind: the server assigns those fields. Errors: JSON {"error":"…"} with 400 for invalid fields or unsupported room operations, 401 for missing authentication, 404 for absent or inaccessible rooms/targets, 409 for a conflicting retry ID, and 500 for an internal failure. Creator-only operations also conceal denied access as 404, rather than exposing another participant’s authority. The successful response for each operation is listed below.

List workspace rooms

GET /api/v1/conversations Query parameters: limit defaults to 100 (1–100); offset defaults to 0 and must be nonnegative. Response: 200 with {"conversations":[],"next_offset":null}. A full page provides a numeric next offset; follow it until null. Only accessible rooms are included.

Create a workspace room

POST /api/v1/conversations Request body: {"kind":"group","title":"Planning","member_ids":["USER_ID"]}. kind is group or channel; title is required (1–120 characters). Members must belong to this workspace; the creator is included automatically. Response: 201 with the conversation object. Creating a channel exposes its history to the workspace; it does not invoke an agent.

Open or reuse a human DM

POST /api/v1/conversations/direct Request body: {"user_id":"OTHER_WORKSPACE_USER_ID"}. The target must be a different current workspace member. Response: 201 for a new fixed-pair DM, or 200 with the existing conversation for the same pair. Repeating this request does not create a second DM or change its participants.

Read room metadata

GET /api/v1/conversations/{conversationId} Path parameters: conversationId. Response: 200 with a conversation object: id, workspace_id, kind, title, created_by, timestamps, last_sequence, the caller’s last_read_sequence, unread_count, muted, access_scope and is_direct. A DM additionally exposes optional peer identity fields direct_user_id, direct_user_name and direct_avatar_url.

Read room messages

GET /api/v1/conversations/{conversationId}/messages Path parameters: conversationId. Query parameters: limit defaults to 100 (1–100). Omit cursors for the latest page; use positive before_sequence for older history, or nonnegative after_sequence for catch-up (0 starts from the beginning). The two cursors are mutually exclusive. Response: 200 with {"messages":[],"has_more":false}; each page is in ascending sequence order. has_more means the page filled the limit, so another request may be empty. Messages include server ID, room ID, sequence, author identity, content, timestamp, retry ID, structured mentions and kind. Optional source_kind=activity marks a trusted system activity message; a missing author alone does not imply that.

Send a room message

POST /api/v1/conversations/{conversationId}/messages Request body: {"content":"Please review","client_id":"send-unique-1","mentioned_agent_ids":["AGENT_ID"]}. Content must contain text and fit within 32768 UTF-8 bytes; a nonblank client_id is required (at most 128 bytes). Agents must already be joined to the channel. Plain @name text does not invoke an agent. Response: 201 with the persisted message; an identical retry returns 200 with the same message. Reusing the retry ID with different content/mentions returns 409. Mention jobs are durable; message acceptance does not mean an agent has already completed its answer.

Mark a room read

POST /api/v1/conversations/{conversationId}/read Request body: {"last_read_sequence":42} with a nonnegative sequence actually read by the caller. Response: 204 No Content. The read cursor is per user and monotonic; this does not mark other participants’ messages as read.

Mute a room

POST /api/v1/conversations/{conversationId}/mute Request body: {"muted":true}; send false to unmute. Response: 204 No Content. This changes the caller’s notification preference, not membership or other users’ preferences.

List room participants

GET /api/v1/conversations/{conversationId}/participants Path parameters: conversationId. Response: 200 with {"participants":[]}; entries contain user_id, name, optional avatar_url and joined_at, with an informational role. For channels this is the explicit roster, not a restriction on which workspace members can read the room.

Add a room participant

POST /api/v1/conversations/{conversationId}/participants Auth: room creator only. Request body: {"user_id":"WORKSPACE_USER_ID"}. Response: 204 No Content, also when already joined. This grants existing room history. A fixed-pair DM rejects roster edits (400); use the continuation endpoint to create a separate group without exposing the private DM history.

Remove a room participant

DELETE /api/v1/conversations/{conversationId}/participants/{userId} Auth: room creator only. Path parameters: conversationId, userId. Response: 204 No Content. Supported for groups and channels, excluding the creator; fixed-pair DMs reject removal (400). Removing a channel member changes the roster, not workspace-wide access, read cursors or mute preferences. Reading, sending and muting do not enroll a user in the channel.

List joined room agents

GET /api/v1/conversations/{conversationId}/agents Path parameters: conversationId. Response: 200 with {"agents":[]}. Entries include agent_id, name, slug, joined_at, and optional avatar URL, style and seed. Deleted or foreign-workspace agents are excluded.

Join an agent to a channel

POST /api/v1/conversations/{conversationId}/agents Auth: room creator only. Request body: {"agent_id":"WORKSPACE_AGENT_ID"}. Response: 204 No Content. Only workspace channels support agents; private rooms return 400. Joining records a visible history-access notice but does not start work. A later structured mention can supply the channel history to the agent.

Remove an agent from a channel

DELETE /api/v1/conversations/{conversationId}/agents/{agentId} Auth: room creator only. Path parameters: conversationId, agentId. Response: 204 No Content. Only channels support this operation; removal records an agent-left notice. Queued mention work revalidates access before execution.

Inspect room agent jobs

GET /api/v1/conversations/{conversationId}/agent-jobs Path parameters: conversationId. Response: 200 with {"jobs":[]}. Job entries expose id, message_id, agent_id, state, assignment_id and error, allowing clients to distinguish accepted work from an answer already delivered to the transcript.

Read channel activity settings

GET /api/v1/conversations/{conversationId}/activity Path parameters: conversationId, which must name a channel. Response: 200 with {"issues":false,"routines":false} by default. Private rooms return 400; any current workspace member with channel access can read settings.

Set channel activity settings

PUT /api/v1/conversations/{conversationId}/activity Auth: channel creator only. Request body: {"issues":true,"routines":false}; both booleans are required. Response: 200 with the saved settings. Enabling publishes future issue/routine lifecycle events into the channel, with no historical backfill and no agent jobs generated by those system messages.

Read-only chat shares

These grants expose only ongoing user/assistant text of one human chat. They do not create workspace membership or authorize files, tools, streaming or agent execution. Tokens expire after 24 hours by default and at most seven days. All four responses carry Cache-Control: no-store. The browser reader is /shared-chat; management is available through crewship chat share and API.

Create a chat share

Auth: Normal session or CLI bearer in the selected workspace; the caller must be the chat creator or a current OWNER/ADMIN. A scoped CLI token also needs agents:write. Machine-origin chats are not shareable. Request: agentId and chatId identify a live direct human chat in the selected workspace. Optional JSON body {"ttl_seconds":86400}; omission or zero uses 86400. Other accepted values are integers 1 through 604800. Unknown fields and multiple JSON documents are rejected. Response: 201 Created with {"share":{...},"token":"cshr_…"}. The share has id, workspace_id, agent_id, chat_id, issued_by_user_id, created_at, expires_at and optional revoked_at (timestamps are RFC 3339). The plaintext token appears only here; the database retains its hash. Statuses: 400 invalid input; 401 missing login; 403 insufficient token scope; 404 missing, mis-nested or unauthorized chat; 500 storage failure.

List chat shares

Auth: Normal session or CLI bearer; current chat creator or OWNER/ADMIN in the selected workspace. Scoped CLI tokens need agents:read. Request: agentId and chatId path parameters; workspace_id selects the workspace. No request body. The list includes expired and revoked metadata. Response: 200 OK with {"shares":[...]} using the metadata described above, newest first. No plaintext token or token hash is returned. No pagination yet. Statuses: 401 missing login; 403 insufficient token scope; 404 missing, mis-nested or unauthorized chat; 500 storage failure.

Revoke a chat share

Auth: Normal session or CLI bearer; current chat creator or OWNER/ADMIN in the selected workspace. Scoped CLI tokens need agents:write. Request: agentId, chatId, shareId must match the selected workspace and one share. No request body. Repeating revocation preserves the revocation time. Response: 204 No Content. The next read request with the token is denied; an in-flight read can finish, and previously downloaded copies remain. Statuses: 401 missing login; 403 insufficient token scope; 404 missing, mis-nested or unauthorized share/chat; 500 storage failure.

Read a shared transcript

Auth: Dedicated cshr_ bearer only. A normal session/CLI token and cookies do not satisfy this authentication. Each request checks token hash, expiry, revocation, current issuer membership/authority and the live chat/agent/crew/ workspace binding. The token does not authenticate any ordinary API/WS route. Request: shareId path parameter. No body; all query parameters are rejected. Response: 200 OK with {"messages":[{"id":"…","role":"user","content":"…", "created_at":"2026-09-27T00:00:00Z"}]}. Roles are user or assistant only. Structured messages expose text parts only; tool calls/results, thinking, attachments and internal metadata are omitted. Future text appears on refresh until expiry or revocation. A missing transcript file is an empty array. Statuses: 400 query parameters supplied; 401 missing/invalid bearer transport; 404 wrong token, expired/revoked share or lost issuer/resource authority; 413 source transcript over 16 MiB or 1,000 physical JSONL rows (including hidden rows), or encoded IPC response over 32 MiB; 502 IPC/read/decode failure; 503 unavailable store/service. Oversized or unreadable history is never silently truncated.