Conversations
Endpoints for the chat surface beyond the basic message list: conversation search, emoji reactions on assistant messages, and file attachments uploaded for an agent. 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.
All endpoints require an authenticated session.
Endpoints
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 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. - 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.