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.
Conversation search
Search conversations
- agent scope —
agent_idgiven. The handler verifies that agent belongs to the caller’s workspace before searching it. - workspace scope —
agent_idomitted. 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.
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
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
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
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
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 tovisibility='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
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
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
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
chats.workspace_id, checked in-handler — same tenancy
contract as the reactions endpoints).
Request body:
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
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
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 atGET /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
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.
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 typespeer.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_idjoin as reactions; mutating additionally requires being the chat creator or a workspace OWNER/ADMIN. - Steering: same
chats.workspace_idjoin; 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
IPCConfigcarries the scope.
Related
- Chat & Sessions guide.
- Crews API.
- Crew Journal —
peer.escalation,peer.conversation. - User Preferences — UI/composer settings.
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 agentchats 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 carryCache-Control: no-store. The browser reader is
/shared-chat; management is available through crewship chat share and API.
Create a chat share
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
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
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
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.