Chat & Sessions
Overview
Chatting with an agent is a first-class surface in Crewship. PRs #213, #214, #223, and #225 re-shaped it from a side drawer into a full-page experience with session history, attachments, message reactions, and a right-hand drawer that exposes what the agent makes and the work connected to it. The split-screen layout from PR #314 finished the job — chat on the left, the agent’s artifacts and work on the right, both live. Since #2458 the same page also carries the conversations between people — direct messages, groups and workspace channels — in one column with the agent sessions; see People, groups and channels. A “session” in Crewship is intentionally durable. It’s achats row scoped to one agent, with a stable ID that survives page reloads, browser restarts, and operator hand-off — anyone with read access to the agent can resume the same conversation history. The schema has grown alongside the UI: PR #225 added chat_branches (edit-and-resend trees), message_reactions, chat_attachments, and workspace_files, plus chats.origin (migration v59) for grouping in the Sessions sidebar by where the conversation started — one of UI / CLI / WEBHOOK / CRON / AGENT, written by POST /api/v1/agents/{agentId}/chats. The point of the breadth: a chat is more than text — it’s a typed transcript backed by structured tables so search, audit, and replay all work the same way no matter which surface produced the message.
This guide covers the model, the URL state, and the surfaces a power user (or integrator) cares about. The component reference lives in the frontend codebase (app/(dashboard)/chat/chat-client.tsx, with the column in components/features/chat/conversations-sidebar.tsx and the rooms in components/features/conversations/) and the persistence layer lives in internal/api/agent_chats.go + the Go migrations under internal/database/migrate.go; here we focus on what the platform persists and how the URL state composes with it.
When to use it
Most chat surface usage is self-evident — “talk to the agent” — but the durable-session design unlocks several flows worth reaching for explicitly:- Resume a multi-day conversation. A session ID survives reloads and operator hand-off, so
/chat/<agent>?session=<chatId>is the canonical “where we left off” URL. Bookmark it, paste it into Linear, share it on Slack — the recipient lands on the same transcript. - Reroll a bad agent response without abandoning context. Hover-edit the user message and the conversation truncates from that point; the new edit sends as a fresh turn. The transcript before the edit is preserved as a chat branch so the alternate timeline isn’t lost — useful when you discover the original direction was right after all.
- Triage what an agent has been doing.
chats.origin(UI / CLI / WEBHOOK / CRON / AGENT) records whether a conversation was started by a user, by automation, or by a peer agent. The open conversation reports its own origin as a chip in the chat header. The conversations column does not group or filter by it — to audit what an integration has been driving through your agents across many conversations, usecrewship chat list, which carries the column. - See what the agent made without opening the journal. The Artifacts panel lists the agent’s client-facing outputs while the chat is open — PDF, images, sandboxed HTML, CSV/TSV — with internal agent/run files (scaffolding,
runs/,attachments/, dotfolders) excluded from the list. Click one to preview it in place, follow live changes, or open it for editing; see Right drawer. - Programmatic chat creation from CLI / webhook / cron.
POST /api/v1/agents/{agentId}/chatsaccepts anoriginenum on the body, which the chat header renders as a coloured chip andcrewship chat listprints as itsORIGINcolumn. UseCLIforcrewship run-style invocations,WEBHOOKfor trigger-handlers,CRONfor scheduled routines,AGENTfor peer-to-peer assignments — it is how an operator tells at a glance whether a conversation was driven by a person or by automation.
/chat/<agent> and start typing — the session row gets created automatically on first send.
Key concepts
Glossary — session, origin, branch, attachment, panel, and friends
Glossary — session, origin, branch, attachment, panel, and friends
Share one read-only transcript
The creator of a direct human agent chat, or a current workspace OWNER/ADMIN, can issue a short-lived read token. The recipient does not need an account or workspace membership. This shares the ongoing user/assistant text, including future messages until expiry or revocation. Review the text before sharing; text can itself contain confidential information.{share, token}; the token is returned only once. --ttl
defaults to 24 hours and accepts 1 second through 168 hours. Listing returns
metadata without token material. Supply the normal --server and --workspace
flags when managing shares on a specific instance.
Recipients open /shared-chat on the same Crewship server, enter the share ID
and token, and select Open transcript. The viewer renders plain text and
supports manual refresh and clearing. It does not put the token in a URL or
browser storage. They can also use:
--token-stdin and an
explicit --server are required; this command does not use the normal profile
credential. Avoid putting the token in command arguments, URLs or shell history.
The corresponding API is POST/GET
/api/v1/agents/{agentId}/chats/{chatId}/shares and DELETE on its /{shareId}
child. Creation accepts ttl_seconds. Scoped CLI tokens need agents:write to create
or revoke, and agents:read to list shares; the issuer must also be the chat
creator or a current OWNER/ADMIN. The reader calls
GET /api/v1/shared-chats/{shareId}/messages with the share token in the
Authorization: Bearer header. Query parameters are rejected, and responses
are marked Cache-Control: no-store.
The reader rejects transcripts over 16 MiB of source JSONL or 1,000 stored
rows, and encoded IPC payloads over 32 MiB, with HTTP 413. Hidden tool rows
count toward these limits. It does not silently truncate the conversation;
large-history pagination is not available yet.
Each read checks expiration, revocation and the issuer’s current sharing
permission. Removing the issuer’s membership invalidates access on the next
request. Existing shares survive a server restart; they are excluded from
workspace backups. Copies already received cannot be recalled.
Shares do not expose structured tool calls/results, reasoning, attachments,
Files, memory, runs, streams or agent execution. The share token cannot log into
normal API or WebSocket routes. This does not restrict the permissions of
existing workspace members and does not implement a client who can converse
with only one agent. Creating and revoking shares currently uses CLI/API;
there is no sharing-management Settings screen yet.
Usage
The end-to-end loop from “I have an agent” to “we had a productive conversation about it” is five steps. Each step has a dedicated section below with the full surface area; this is the orientation walk-through.1
Open the chat
?session=<chatId>. To approach from the canvas instead, use /crews?crew=<slug>&agent=viktor — see Routes.2
Send a message
Type into the composer and hit Enter. The agent’s response streams back via WebSocket; the presence chip in the footer shows
thinking while the model is generating. Useful composer tricks (full detail in Composer):⌘/(or the Commands button in the chat header) opens the slash palette for structured commands (/skill <name>,/credential, …).⌘Kis the workspace palette in the top bar, not this one.- Drag, paste, or click-upload an image / file — capped at 25 MB, surfaced to the agent under
/output/<agentSlug>/attachments/…. The crew must be running: that directory is owned by the crew runtime, so the upload is written through the crew container. - Pick a different model from the dropdown next to Send for this turn only (handy for switching to Opus mid-conversation for a hard step).
3
See what the agent made
While the agent is acting, open the right drawer — see Right drawer for the two panels:
- Artifacts (
⌘1) — the agent’s client-facing outputs: PDF, images, sandboxed HTML and CSV/TSV preview in place (XLS/XLSX offer a download); internal agent/run files are excluded from the list. An artifact opens inline or expanded, can follow live changes every five seconds, and opens and saves through the agent’s real file routes. - Work (
⌘2) — the issues assigned to and routines authored by this agent, and the exact issue/routine source of the open conversation when it has one, with links to the linked transcripts.
⌘B toggles the drawer. What the agent is running lives in the Crew Journal and on the crew page, not in the chat.4
React or reroll
Hover an assistant message to surface its action row: Copy, and the 👍 / 👎 feedback thumbs. The thumbs write
message_feedback, which carries a trace_id back to the run that produced the answer — that is the signal the agent and the eval loop read. There is no emoji picker in chat: emoji reactions still exist and still render back on a turn that has them, but they can only be added with crewship chat react. Hover a user message instead to surface Edit — editing rewrites that turn and truncates the conversation, but the old branch lives on in chat_branches and can be revisited later via the branch picker. See Edit-and-resend.5
Resume later
Just copy the URL (Copy link in the header does the same). The session ID is in
?session=<chatId>. Bookmark, paste into Linear, share over Slack — anyone with read access on the agent lands on exactly the same view (their reactions are scoped to them, the session itself is shared). Star the agent in the sidebar (Favorites) if it’s a long-running thread you want to keep above the timestamp-sorted list.origin field on the create payload stamps the right sidebar chip so an operator scrolling the list can tell at a glance which conversations came from automation.
Examples
Pair-debugging a failing test
A test is red in CI and you want the agent’s eyes on it. Open the agent and start a new session:/output/viktor/attachments/<chatId>/snapshot.txt inside the container, and the agent reads it from its own filesystem. As the agent works, its commands and tool calls stream into the Crew Journal (filter by the agent, or by the run’s trace_id); when it writes a report or a patch, the Artifacts panel (⌘1) is where you open it.
When the suite goes green, 👍 the assistant’s final message — the reaction surfaces back to the agent on the next turn as positive signal and shows up in Quartermaster replays as coarse human feedback.
Webhook-driven issue triage
A GitHub issue webhook fires; your trigger handler posts to Crewship:session_id?, origin?); the
opening message is sent as a follow-up turn on the returned chat id.
The new session shows up in the agent’s sidebar with a coloured WEBHOOK chip, so an operator scrolling the list can distinguish auto-triaged issues from human-initiated conversations at a glance. The agent processes the message, does its triage, posts a summary — and a human can take over the conversation by opening ?session=cht_xyz789 and replying directly.
Cross-operator escalation
You’re chatting withviktor about a production outage and realise this needs an SRE’s eyes. Star viktor in the sidebar (Favorites) so you can come back to the thread and copy the URL:
chats table, distinguishable only by their origin chip in the sidebar.
Long conversations & context compaction
A session is durable on disk, but each agent turn only sees as much of the transcript as fits the model’s context window. The orchestrator (internal/orchestrator/orchestrator_run_conv.go) sizes a conversation budget and fills it newest-first, so the most recent turns are always present in full.
When a conversation outgrows that budget, the older turns are compacted rather than dropped. If an auxiliary model is configured (the same summarizer Crewship uses for memory consolidation — the curator aux slot, falling back to KEEPER_OLLAMA_URL + KEEPER_MODEL), the overflow is summarized into a short block prepended to the verbatim recent window:
…(truncated) marker on the boundary message, exactly as before.
Whenever older turns overflow the budget — whether they were summarized or dropped — the orchestrator emits a conversation.compacted entry to the Crew Journal carrying session_id, overflow_messages, summarized, and summary_bytes. Query it with crewship journal --type conversation.compacted to audit what fell out of an agent’s context window over time.
Temporally-anchored summaries
The summary is written as dated past-tense facts, not open instructions. The aux model is told today’s date (UTC) and asked to rewrite completed or imperative actions accordingly — “Sent the report on 2026-06-09” rather than a bare “email the report” that a resumed agent could mistake for a still-pending task and re-run. When a date can be recovered from the transcript itself the model prefers it; otherwise it anchors to “around <today>”. This keeps long-running sessions from silently re-issuing work that already completed before the older turns scrolled out of the window.Robustness at the overflow boundary
Two guards keep compaction safe on long, tool-heavy sessions:- Tool context is never half-shown. When the message that sits on the overflow boundary carries a tool-result summary, it is dropped into the overflow whole (where the summarizer can still capture it) rather than truncated into a half-sentence that references a tool whose result was cut. The one exception is a lone oversized message — it is still truncated so the recent window is never empty.
- Summaries are session-pure. Compaction operates strictly on one session’s history; if a future change ever mixed turns from different sessions into one overflow slice, the orchestrator detects the mismatch and falls back to truncation instead of risking one session’s content leaking into another’s summary.
API reference
The chat surface is small — no dedicated/api-reference/chats page yet; the handler source is internal/api/agent_chats.go. Every endpoint requires Authorization: Bearer <CLI token> or a valid session cookie, and access is gated by the caller’s read/write role on the parent agent.
Sessions (chats)
Messages
Reactions
Reaction endpoints require an authenticated workspace member.
GET returns
200 OK with { "reactions": [{ "emoji": "👍", "count": 1, "mine": true }] }; an
unauthenticated or non-member GET returns 404. DELETE takes emoji as a
path parameter and returns 204 No Content; it returns 401 when unauthenticated
and 404 when the chat is unavailable to the caller.
Attachments
Participants
GET /api/v1/chats//participants
Auth: Authenticated workspace caller. Request:chatId path parameter; no body. Response: 200 OK with { "participants": [{ "user_id", "email", "full_name", "role", "joined_at" }] }. Statuses: 200 OK; 404 Not Found for an unknown or cross-workspace chat.
POST /api/v1/chats//participants
Auth: Authenticated chat owner or workspaceOWNER/ADMIN. Request: chatId path parameter and JSON { "user_id": "…", "role": "member" | "owner" }; role is optional. Response: no body. Statuses: 204 No Content; 400 for a non-member target or an attempt to remove the owner, 403 for an unauthorised manager, and 404 for an unknown or cross-workspace chat.
DELETE /api/v1/chats//participants/
Auth: Authenticated chat owner or workspaceOWNER/ADMIN. Request: chatId and userId path parameters; no body. Response: no body. Statuses: 204 No Content; 400 when removing the chat owner, 403 for an unauthorised manager, and 404 for an unknown or cross-workspace chat.
Access and tenancy rules:
- Scoped via
chats.workspace_id— a cross-tenant or unknown chat returns404. - Mutations (
POST/DELETE) require the chat creator or a workspaceOWNER/ADMIN; otherwise403 only the chat owner or a workspace admin can manage participants. A participant whoseroleisowneris not thereby granted this right — manage-participants permission comes only from being the chat creator or a workspace admin, independent of the per-participantrolelabel. - Plain members can
GETthe participant list but cannot mutate it. - The target user must already be a workspace member, else
400 user is not a member of this workspace. - Removing the chat owner is rejected with
400 cannot remove the chat owner.
Mid-turn steering
Sometimes you want to redirect an agent that is already mid-turn — “actually, focus on the auth bug first” — without waiting for the current run to finish or starting a competing second run in the same container.POST /api/v1/chats//steer
Auth: Authenticated caller allowed to operate the chat. Request:chatId path parameter and JSON { "message": "…" }. Response: JSON { "queued": <bool>, "in_flight": <bool> }. Statuses: 202 Accepted; 400 for a missing message, 401 without authentication, 404 for an unknown or cross-workspace chat, 422 for a steering rejection, or 503 when the chat bridge is unavailable.
The CLI parity is crewship chat steer:
Today’s behavior is queue-for-next-turn, not live injection. When a run is already in flight for the chat, the bridge detects it (an in-flight guard tracks live runs per chat) and queues the steering message rather than spawning a second run that would race the first into the same container. The queued message is persisted to conversation history tagged as a steer, and a
steering_queued event is emitted on the chat’s session:<chatID> WebSocket channel; the agent picks it up on the next turn like any other user message.in_flight in the response tells you whether a run was live when you steered (true → it lands after the current turn finishes; false → it lands at the start of the next turn you kick off). The steering text is run through the same content scan as memory and tool output, so a prompt-injection payload is rejected with 422 and nothing is persisted.Live injection into a running turn — interrupting the model’s current generation to feed it the new instruction immediately — is a planned follow-up, not in this release.Realtime (WebSocket)
The chat page subscribes to the per-session WebSocket channel (session:<chatID>) and listens for the events the runtime actually emits. There are no chat.message.* or chat.reaction.* events today — message turns are read back via REST after a successful POST, and reactions are local-state-only on the client.
Each event triggers a REST re-fetch of the affected resource — the server-side state is authoritative and the next render is a function of that, not of imperative local mutations. Reconnect is idempotent: sends in-flight at disconnect time carry the same client
request_id and the server dedupes.
Routes
Three top-level routes serve chat:
The session is a query parameter, never a path segment. The static export serves
/chat/<slug> through a one-level rewrite in internal/api/static.go, so a deeper /chat/<agent>/<session> would resolve to the wrong page shell.
The /chat/<agentSlug> URL is deep-linkable — the session query parameter scopes to a specific chat history. Without ?session, the route opens the agent’s most recent chat. With a session ID, it scrolls to that chat’s history and resumes the conversation in place.
For an explicit new conversation, use /chat/<agentSlug>?new=1. This mints a
local draft ID and creates no server row until the first message is sent. A
session parameter does not override new=1. A handoff with
?new=1&draft=1&prompt=<text> puts text into the composer without sending
it; an existing unsent draft in the selected session takes precedence. The
legacy ?prompt=<text> link still sends once automatically, so use the draft
form when a person must review the message. Query text is visible in browser
history and should not contain secrets. Ask about this Page opens a new,
unsent agent session with ?new=1&page=<slug>; the URL contains only the Page
reference. The Page chip can be removed before sending. At send time the server
rechecks that both the person and selected agent can reach the Page through a
current grant. It then appends a bounded, explicitly untrusted snapshot of the
Page’s identity (name, slug and ID) to the message and records server-derived
page_context provenance. Panel payloads and application data are not copied.
If either reader loses access, the send is refused without persisting the
message. The recipient needs an explicit Page grant; opening the draft does
not issue one. The transcript shows the Page provenance after history reload. The
Page chip is one-shot and is consumed only when the server confirms the message
was saved: a send that is refused or disconnects keeps the draft and the Page
reference, so the person can retry without re-adding it. Regenerate on a
Page-attributed reply requests a fresh snapshot — access is rechecked and the
old server-attributed suffix is stripped rather than copied — and never replays
an ask-form submission.
The state of which message is being edited, whether the slash palette is open, and the like is held in shallow router state — useShallowSearchParam writes to window.history.replaceState directly without triggering a Next.js navigation. This keeps the page interactive (no rerender on tab switch) while still giving you a copyable URL for any state.
Sessions
A “session” is achats row. Sessions persist across page reloads, browser sessions, and operators (any user with read access to the agent can see its history). Migration v59 added the chats.origin column (DB-side; the Prisma TypeScript schema is intentionally not regenerated for this column — Prisma is types-only in this project). The Go handler at POST /api/v1/agents/{agentId}/chats accepts an origin field on the body and whitelists these values:
Anything else (or empty) is stored as
NULL so a rogue caller cannot shove arbitrary text into a UI-rendered chip. The chat header renders the origin as a status pill in words — Routine step, Scheduled, Webhook, From the CLI, Delegated; a chat started in the browser (UI) and a NULL row wear none. A routine-step or issue conversation also shows its exact persisted source beside the chip — a Routine · <name> / Issue · <name> link that opens the Work panel on that routine or issue; legacy conversations without persisted provenance show their origin but no guessed link. Sessions can be renamed or archived, and agents (not individual sessions) can be starred into Favorites.
Who you are talking to
The header of a conversation is the agent strip: the agent’s face with a status dot (blue with a halo while running, green idle, red on error), its name, its status as a word, its role, its crew (a colour dot and a link), its model as a label — never a model id — and N skills · M credentials · runs as links into the crews canvas and the journal. The connection badge appears only while the socket is not connected; Commands opens the chat palette; Copy link puts the conversation’s address on the clipboard (the session id used to be printed raw in the header — the address is the one form of it a person can use). The toolbar breadcrumb is a path of names — Crews / Ops / Riley / Chat — every crumb a link, published by the chat page once the roster resolves. It never shows the agent’s slug, and never the onboarding Guide’s internal identifier. An empty conversation is the agent card plus What <name> can do: the agent’s own skills, each with a Try it prompt that opens the conversation from that skill. An agent with no skills gets one line and Add a skill →. A routine step or an issue chat is a transcript, not something to start: its empty state says “This step has not written anything yet” and links to a direct chat with the agent.The column folds
The column asks the server for ten conversations per agent and readsX-Total-Count; each agent’s list folds to five sessions (the active one always kept) behind Show all N loaded sessions, and Load older sessions fetches the rest of that agent’s page. Searching is server-side: the search box queries names and session titles on the server (Unicode case-folded) before pagination, so it matches sessions the sidebar has not loaded — additional result pages load explicitly with Load more matching sessions. Not started yet lists agents you have never talked to as rows with a Start verb — six, then “N more agents” opens the picker. The onboarding Guide (a kind=setup crew) is hidden from the roster by the server; a deep link into the Guide’s own conversation still opens, because the page asks once more with include_setup=1 when the address names an agent the roster does not carry.
Chat sessions honor the workspace preferred_language — the agent replies in it. Set it with crewship workspace update --language <code> (see crewship workspace).
Session names
A session names itself from its first message.chats.title had been in the schema since the first migration with nothing writing it, so every thread in every list read “Untitled session” — which makes a list of conversations unusable the moment there is more than one. The chat page now derives a title from the opening message and writes it with Rename Chat.
The derivation (lib/chat-title.ts) is deliberately dumb and local — no model call, no round trip before your message goes out:
- whitespace collapses, the text is trimmed, control and invisible formatting characters are dropped (the same normalisation the server applies on write, so the value you see is the value stored);
- it is cut to 60 characters at a word boundary, with an ellipsis only when something was actually cut. A single token longer than that is the one case a word is broken, because the alternative is a base64 paste as the thread’s name;
- a message that is only an attachment is named after the file;
- a message that yields nothing usable — empty, whitespace,
???— leaves the session untitled. “Untitled session” is a poor name; a thread called…is worse.
- It fires once, on the first message of a session that has no title. Rename a thread yourself and nothing will ever overwrite it — not the next message, not a reload.
- It never blocks the send. The message goes out first and the title follows as a separate request. If that request fails the message is unaffected and nothing is reported; the session simply stays untitled, which is where it already was.
- A rename does not reorder the sidebar. The server does not bump
last_activity_aton a rename, and the client splices only the title into the row it already holds — so fixing a name never shoves a thread to the top.
crewship chat rename <chat-id> "<title>" or the API. The new title is broadcast on the workspace channel as chat_renamed ({agent_id, chat_id, title}), so a session list open in another tab repaints without polling.
Unread badges & last activity
The Sessions sidebar orders by last activity — a session jumps to the top whenever a message lands in it, regardless of when it was created (chats.last_activity_at, migration v130). Sessions with replies you haven’t seen show a subtle blue unread badge (dot + count). Read state is per user: a chat_read_cursors row records the last moment you marked the chat read, and unread_count is everything appended after that cursor that you didn’t write yourself. Opening a session marks it read automatically; the badge and the inbox notification clear together.
From the CLI, the same data drives crewship chat list (an UNREAD column plus LAST ACTIVITY ordering), and crewship chat read is the mark-read parity command. --limit N narrows the page (newest first; the server’s default and ceiling are 100), --offset pages, both compose with --kind, and a cut page ends with a footer (showing 1–10 of 19 · next page: --offset 10):
GET /api/v1/agents/{agentId}/chats follows the paging convention: ?limit=&offset= on the request (limit 1–100, default 100), the body is the page, and X-Total-Count carries the number of chats in the same kind partition — so a conversations column can say “13 more with Riley” and mean direct conversations, not routine steps. ?q= (title search), ?chat_id=, ?routine_id= and ?source=1 all apply before the page is cut; see List Chats.
“Your agent replied” notifications
Long-running turns shouldn’t require babysitting the tab. When an assistant reply is persisted for a session and a chat user is not currently viewing it (no live WebSocket subscription onsession:<chatId>), Crewship writes a message-kind item into the unified inbox: agent name, session title, a scrubbed ~120-character preview, and an Open chat deep link to /chat/<agentSlug>?session=<chatId>.
Behavior worth knowing:
- Presence-aware. Watching the reply stream in live means no notification — the bell only rings for replies you’d otherwise miss (closed tab, navigated away, network drop mid-run).
- One item per (user, session). Repeated replies refresh the same unread bell item (timestamp + preview) instead of stacking; a new reply after you dismissed the old item re-notifies.
- Group chats notify every participant except the person whose message triggered the run.
- Previews are scrubbed through the same credential scrubber as logs, so a reply that quotes a token never leaks it into the inbox.
- Opening the session (UI) or
crewship chat readclears the item along with the badge.
Faithful reload
Reopening a session now rehydrates the full structured turn — assistant text, reasoning/thinking, and tool call cards (input + result) — not just the final text. Previously thinking and tool activity were dropped on reload and only the last assistant message survived. Persistence is built on a normalizedPart model over the adapter-neutral event stream, so the rehydrated transcript is identical across every CLI adapter (the one in use today and any added later); legacy JSONL transcripts remain readable. MVP limit: tool_result bodies are stored as a correlation id only.
How assistant turns render
- Streamed text reveals at a smooth, constant rate. Network chunks arrive in bursts; the renderer buffers them and reveals characters at a steady pace that speeds up when a backlog builds (e.g. after a reconnect replay), so replies read as a calm flow instead of sentence-sized pops.
- All reasoning of a turn renders as ONE chain-of-thought block. Models interleave thinking ↔ text ↔ tools freely (fast models produce a dozen short passes per reply); every pass merges into a single collapsible block at the position of the first one. The block starts closed and is never opened for you — while the model reasons, its header shows a live shimmering
<agent> is thinking… Nstimer that accumulates across passes, and when the turn finishes the header settles to “Thought for N seconds” (total). Click the chevron to read the reasoning itself; it is kept, not discarded, so a wrong answer can always be opened up after the fact. Transient progress lines never split the block — a streamed turn renders identically to the same turn reloaded from history. - Your question pins to the top on send. After you send a message, the conversation anchors your message at the top of the viewport and the reply streams in below it; once the reply outgrows the screen, normal follow-the-stream scrolling (with the scroll-to-bottom pill) resumes.
- Consecutive tool calls collapse into one “Worked · N steps” disclosure with per-tool input/output. A single tool stays a plain card. Interactive tools (
AskUserQuestion,TodoWrite,Task) are never grouped. - Bursts of internal progress / thinking chatter collapse into a single quiet live status line rather than scrolling the transcript.
- Subagent output is indented under its delegation. Text, thinking, and tool calls from a delegation (via the
Tasktool) are tagged withparent_tool_use_idand rendered indented beneath the delegation in the transcript.
Composer
The bottom-of-page composer is a single textarea with three superpowers:-
Slash palette — typing
/at the start of a line opens a fuzzy-search palette over registered slash commands (e.g./eval,/checkpoint,/skill <name>). Commands are registered server-side so a multi-line/eval my-suite --baseline last-weekis parsed as a structured command rather than a chat message. -
Model picker — dropdown next to the send button overrides the agent’s default
llm_modelfor this turn only. Useful when an operator wants to spend Opus on a hard reasoning step inside an agent that normally runs Sonnet. -
Attachments — paste images, drag files, or paste image data from the clipboard. Files are uploaded via
POST /api/v1/agents/{agentId}/chats/{chatId}/attachments(handlerProxyHandler.AgentChatAttachmentininternal/api/proxy_attachments.go) and written to the server’s storage provider as a blob. The agent’s container sees the file under/output/<agentSlug>/attachments/<chatId>/<attachmentId>/<filename>. 25 MB cap per upload. The message names the file. Uploading puts bytes in the container; it does not tell the agent they are there. So when the composer sends a message with attachments, it appends a block naming each one by the path relative to the agent’s working directory (which is/output/<agentSlug>/, so thepaththe upload returned opens as-is):The payload is an ordinary user message — no new envelope, no adapter-specific framing, nothing the agent has to be trained for. The wording is fixed inlib/attachment-message.tsand covered by a table-driven test; the transcript shows the block verbatim, because it is exactly what the agent received. Three consequences worth knowing: a message with no text at all still sends when something is attached (a photo needs no caption); Send is refused while an upload is still in flight, rather than sending a message that names only the files that finished; and the block counts toward the 64 KiB WebSocket frame cap, so a draft that fits on its own can be refused once attachments are added — the draft and the attachments both survive the refusal. The upload also records a row inattachments(owner_type = 'chat') with the filename, resolved content type, size, SHA-256 and uploader — #1768. The blob stays under/output/<agentSlug>/...on purpose: that path is what prompts, tools and users already reference, so chat attachments are not content-addressed the way issue attachments are, andstorage_keyon the row is what says where each one lives. What the path gained is an<attachmentId>segment before the filename, which makes one upload one identity at one location — two different files calledevidence.pdfno longer overwrite each other. Listing and deletion live atGET/DELETE /api/v1/agents/{agentId}/chats/{chatId}/attachments[/{attachmentId}]; there is still no download endpoint.
Ctrl+K opens the palette unconditionally.
Edit-and-resend
Hovering over a user message reveals an “Edit” button. Editing the message rewrites that turn and truncates the conversation — every message after it is dropped. The new edit is sent as a fresh user turn. This matches the ChatGPT/Claude UX and lets operators reroll an agent’s response without abandoning the whole session.Message reactions
An assistant message renders any emoji reactions it already carries (❤️ 🤔 🚀 …). Reactions are workspace-shared — if two operators are in the same workspace, they each see the other’s. Migration v57 added themessage_reactions table. They are added from the CLI only (crewship chat react); the chat surface reads them back but offers no picker, because in a one-person conversation an emoji has no audience. The 👍 / 👎 in the message action row are a different mechanism — message_feedback, not message_reactions.
The List response is aggregated (one row per emoji, with
count and mine boolean) — not a per-user list. This keeps the FE’s 👍 3 (you) rendering a single fetch.
Reactions are surfaced to the agent on the next turn as part of the conversation context: the model sees that a previous response was 👎‘d by an operator and can adjust. They are also surfaced in Quartermaster replays as a coarse human-feedback signal.
Right drawer
The right edge of the chat is a labelled rail; a button opens its panel in a drawer beside the conversation (⌘B toggles it, the shortcut number is the button’s position). Which panel is open is remembered per browser — it is not part of the URL. In push mode the drawer opens beside the conversation rather than over it (it starts open), and its width drags — or arrow-keys — between 280 and 520 px, saved per user.
Artifacts
An artifact previews inline in the drawer or expanded over the conversation. Follow re-reads the file every five seconds while the agent keeps writing (editing pauses the follow until saved or discarded); PDF pages through with zoom, images fit to the pane, HTML renders sandboxed, CSV/TSV render as a table, and XLS/XLSX offer a download instead of a preview. Open loads the file’s real bytes through the agent file-download route (a response that is not a file stream is refused rather than rendered), and Save writes edits back through the file-save route — the pane never edits a directory listing.Work
Work answers “where did this conversation come from, and what is this agent doing”: the issue/routine source of the open conversation (persisted at creation, never guessed from the editable title), the agent’s assigned issues and authored routines. The same source link sits beside the origin chip in the chat header; clicking it opens this panel on that routine or issue. There is no terminal or diff in the chat: what an agent is running is the Crew Journal’s job (exec.command entries carry the command), and the agent’s configuration lives on the crew page, not in the per-session drawer.
Presence and reconnect
The composer footer shows a small presence chip — “online”, “thinking”, “blocked”, or “offline” — driven by the Watch Roster. When the WS connection drops, a reconnect banner appears at the top of the chat (“Reconnecting…”), the composer disables sends, and queued user messages are flushed once the socket re-establishes. Network blips do not lose messages. Sends that were in-flight at disconnect time are retried with the same client-generatedrequest_id; the server idempotently dedupes by request_id so a retry never duplicates a turn.
URL state cheat-sheet
These query params are stable. Do not link to the Next.js internal route segments; those are subject to refactoring.
Common pitfalls
Footguns and surprising behaviour (11)
Footguns and surprising behaviour (11)
- Edit-and-resend truncates the active branch — but the tail isn’t gone. Editing a user message moves every message after it into
chat_branches. The UI shows a small “view earlier branches” affordance, but a hurried operator may believe they’ve lost the alternate response. Train the muscle of opening the branch picker before assuming work is unrecoverable. - Reactions are workspace-shared, not per-user-private. An emoji reaction added with
crewship chat reactis visible to every other operator in the same workspace, and is also surfaced to the agent on the next turn. Use reactions as a deliberate signal — not as a private bookmark. If you need a private flag, use the Inbox pin instead. (The 👍 / 👎 in the message action row aremessage_feedback, a separate table — see Message reactions.) chats.originsilently NULLs unrecognised values. Anything outside theUI / CLI / WEBHOOK / CRON / AGENTallow-list gets stored asNULLto keep the UI chip safe from injection. A webhook handler that postsorigin: "github"will succeed (200 OK) but show no chip — the silent failure is by design. Validate the enum on your side before posting.- Prisma TypeScript schema is intentionally stale on
chats.origin. Prisma is types-only in this project; the v59 migration was applied to SQLite but theprisma/schema.prismawas deliberately not regenerated. TS callers must queryoriginvia the Go-typed API surface, not via Prisma Client. - Attachment cap is 25 MB, with different error surfaces by client. Uploads larger than 25 MB return a 413 from
ProxyHandler.AgentChatAttachment. The browser composer surfaces this as a toast, while a programmatic uploader (CLI, webhook) sees only the HTTP error — check status codes before assuming the file landed. - The Artifacts panel shows the agent’s files as they are now, not a commit. It lists the live storage tree; a file the agent is mid-way through writing is what you get (Follow re-reads it every five seconds). To see what was committed, ask the agent, or read the
exec.commandentries in the Crew Journal. request_idmust be stable per logical send, not per React render. Generate the UUID once when the user hits Send, not in the render function. A re-render-derived ID lets the server treat a retry as a new turn — defeating the dedupe contract.- Slash palette commands must be registered server-side.
/fooin the composer wherefooisn’t a registered command sends the literal text as a chat message — there’s no client-side “unknown command” error. Commands are served fromGET /api/v1/slash-commands(handlerinternal/api/slash_commands_handler.go) rather than discovered by the UI. - Shallow router state evaporates on hard navigation. The editing and palette state survives in-app navigation and back/forward, but a hard reload that hits a 3xx redirect (e.g. auth expiry → login → bounce) loses everything past
?session=…. Bookmark with intent, not panic. - WS reconnect dedupes by
request_id, not by content. Two genuinely different sends that collide on a re-usedrequest_idwill silently merge into one turn server-side. Use a fresh UUID per logical send. - A turn never ends in silence — two explicit error states cover the edge cases. A run that finishes without producing any output (a swallowed safety refusal, prompt-budget pressure, or the agent CLI exiting cleanly with no stdout) shows “The agent returned no output — try again” as an error bubble, and the run is recorded as FAILED. A reply cut off by a hard server stop (OOM/SIGKILL/power loss) gains “The agent’s reply was interrupted by a server restart — try again” on the next boot. Both turns are persisted, so they appear on reload as well as live; use Regenerate (or resend) to retry.
People, groups and channels
The same/chat page carries the conversations between people — with or without an agent in the room. They live in the left column beside the agent sessions (People for direct messages, Team spaces for groups and channels) and open at /chat?conversation=<roomId>. Three kinds:
A DM or a private group can be continued into a new room without exposing its history: Add people turns a DM into a fresh private group with the same two people plus whoever you add; Invite agent starts a workspace channel with the selected agent. The new room begins empty (a channel carries only the agent’s join notice) — the private messages stay where they were. Unread counts sit on each room row, and a room’s message pane marks it read as you open it.
Rooms are a separate table from agent sessions: their IDs and message sequences never mix with
chats, and the CLI reaches them through crewship chat room … (list, create --kind group|channel, direct <user-id>, get, messages, send, participants add|remove|list, agents add|remove|list). The API contract — including the required client_id retry key on create and continue — is in Workspace rooms.
Notification sounds
Settings → Notification sounds (/settings?tab=sounds, also in the user menu) chooses a per-browser sound for two categories: Chat — a new message in one of your rooms, or an agent finished a reply in a session you have open — and Inbox — a new inbox item, escalation or waitpoint arrived. Five presets (Soft pop, Glass, Chime, Inbox drop, Attention) or Off per category, one volume slider, a Do not disturb switch that silences everything but the Preview buttons, and Enable sounds as the master switch. Browsers only play audio after a click, so the page asks you to Activate audio once per session; a reply that lands in several open tabs plays once, not once per tab (the tabs coordinate through shared storage — a browser without it can still preview but will not play automatically). The setting is saved in the browser for this account and workspace, not on the server.
Image and PDF previews
A PNG, JPEG, WebP or PDF opened from the Artifacts panel previews inside the pane instead of downloading: an image fits to the pane; a PDF pages through with zoom (50–300 %). A password-protected or damaged PDF says so and offers the original file. Download is on the preview header for every previewable file; other file types open in the editor or download directly.Group chats on an agent session (legacy)
This is the older way of putting several people in front of one agent: extra participants on an agent session (
chats row), managed from the CLI only. The web UI creates shared conversations as groups and channels instead, which have their own rooms, rosters and history. Both keep working; new shared conversations should be rooms.@riley can you check the logs) and it
runs as usual. In a private 1:1 chat every message runs the agent, unchanged.
Managing participants (CLI). Adding the first participant flips the chat to
group visibility. You can only add people who already belong to the workspace.
Only the chat owner or a workspace OWNER/ADMIN can add or remove
participants, and the chat owner cannot be removed.
--role flag accepts member (default) or owner; it maps to the role
field of the POST /api/v1/chats/{chatId}/participants body, and the same values
appear in GET responses. DELETE takes no role. The label is informational —
it does not by itself grant manage-participants rights.
Attribution & live sync. Each human message is broadcast to the other
participants in real time and labelled with its author (avatar + name); your own
messages render as before. A Group · N badge in the header shows the
participant count.
MVP scope / gotchas.
- The agent runs under the chat’s own credentials, the same for every participant — there’s no per-user data grounding yet. Don’t share a group chat with people who shouldn’t see what the agent can reach.
- Mention matching is by slug on a word boundary (case-insensitive), so
email@riley.comand@rileybotdo not mention agentriley. @mentionautocomplete is available in the composer (wired in PR #702), so you can pick the agent from a dropdown instead of typing the slug by hand. Only presence / typing indicators remain unshipped; live message sync works today.- Adding a participant does not introduce per-message visibility controls — anyone who can see the chat sees the whole transcript.
Related
- Activity — the live canvas where the same agent’s mission runs show up; the chat is one operator surface, Activity is the other.
- Watch Roster — the source of truth for the composer presence chip (
online/thinking/blocked/offline). - Quartermaster — replays consume message reactions as a coarse human-feedback signal (👍 / 👎 surfaces back to evals).
- Skills — slash-palette commands like
/skill <name>route into the skills registry rather than being treated as chat messages. - Hooks — server-side handlers that can drive automation off agent lifecycle events. (Note: there are no
chat.message.*/chat.reaction.*events today — see Realtime above.) - Files and output — the
workspace_filesblob index that the chat’s file and artifact surfaces read from; same view the agent’s container sees. - Keeper — credential injection for tools the agent invokes from inside a chat; keeps secrets out of message bodies.
- User preferences API — composer settings (default model, slash-palette behaviour).
- Conversations API — message reactions, attachments, crew messaging, and the workspace rooms behind direct messages, groups and channels.
- Webhooks — programmatic chat-session creation (
origin=WEBHOOK).