The whole list
All character counts are characters, not bytes — a Czech or Japanese
value gets the same length as an English one.
Kinds are what keeps this page limit honest
The ten-threads-per-agent bound above used to be the worst limit on this page, and it was not obvious why: ten looks generous for a sidebar. It was not a sidebar bound. Four different things write rows intochats —
a person opening a conversation, a routine minting one chat per step, an
issue starting work, an agent delegating to another agent — and
GET /api/v1/agents/{id}/chats returned all four in one list ordered by last
activity. A five-step routine on a nightly schedule writes five rows a night.
Two nights and a person’s newest conversation is row eleven; six weeks and it
is row three hundred.
So the limit did not bury conversations below the fold. It evicted them
from the query, before any client-side filter could see them — which is why
narrowing on the client was never a fix.
The endpoint now takes a kind parameter, and the conversations column sends
it as part of the fetch rather than filtering the response:
Two properties are load-bearing, and both are tested
(
internal/api/chat_kinds_test.go):
- The partition is total and disjoint. Every row matches exactly one kind.
directis written as the negation of the other three rather than as an allowlist, so a row with an origin nobody has thought of yet — a future value, a restore from an older schema — classifies asdirectand stays visible. A row that belongs to no bucket would be unfindable with nothing on screen to say so, which is the failure this whole mechanism exists to prevent. - It never reads the title. A title is user-editable
(
PATCH .../chats/{id}), so a rule over it would reclassify a row the moment somebody tidied its name, and misfile a human conversation the moment somebody called one “Pipeline notes”. Classification reads onlymodeandorigin, which are written by the code path that created the row.
kind parameter, and
switching bucket re-runs the fan-out. crewship chat list --kind is the same
parameter from the terminal.
Because the fetch is scoped, the buckets you are not looking at cannot be
counted from the response. ?counts=1 adds one GROUP BY (mode, origin) over
the agent’s chats and returns the totals in an X-Chat-Kind-Counts response
header (direct=3,routine=182,issue=0,agent=1). It is opt-in because the count
spans every chat the agent has, not the page — crewship chat list has no tab
strip to fill and should not pay for it. The groups are folded through the same
ChatKindOf the filter uses, so the number on a bucket cannot drift from the
list behind it. A client that does not see the header shows a count only on the
bucket it actually fetched.
Upgrading instances get a one-time backfill
(
migrate_consts_chat_origin_routine.go) that stamps origin = 'ROUTINE' on
routine-step chats written before the runner started stamping one. It is
guarded on created_by IS NULL as well as on the runner’s exact title shape,
so a person’s conversation cannot be caught by it.The thirteenth agent
This is the one worth reading in full, because it is the only limit on this page that removes something from the product’s primary navigation with no signal at all. The chat surface does not have a “recent conversations across the workspace” endpoint. It builds that list on the client: oneGET /api/v1/agents, then
one GET /api/v1/agents/{id}/chats per agent, in parallel, merged and sorted
by last activity. That fan-out is capped at 12 agents, because a workspace
with sixty agents would otherwise spend sixty requests to draw a column on a
surface whose whole job is to open fast.
The cap bounds the thread lookup, not the roster — every agent is still
reachable, and starting a conversation with one works normally. What it costs
is the conversations already there. On a workspace with more than twelve live
agents, the existing conversations of agents 13 and beyond never appear in
the column, and because the column lists conversations rather than agents
there is no row of any kind standing in for them. Nothing on screen marks the
absence.
That is the defect, and right now it is the larger of two. useChatTreeData
still distinguishes “this list failed to load” from “this list is empty” —
it returns a per-agent threadErrors map precisely so a 500 is never rendered
as “no conversations” — but the conversations column does not read it. So a
capped agent and an agent whose fetch just failed reach the reader the same
way: silently absent. Both are presented as an agent with no history, which for
a busy thirteenth agent is simply false.
Ordering is what keeps this from being worse than it is: GET /agents
returns live agents by creation recency with retired ones last, so the twelve
that do get a lookup are the twelve newest live agents.
Which agents am I losing? Sort your roster by creation date, newest
first, ignore retired agents, and count off twelve. Everything below that
line has an unreadable thread list in the conversations column.
Naming an agent exempts it. Opening that agent directly at
/chat/<agent-slug> — the shape crewship open <agent> and every
crews / dashboard “Open chat” link already build — sorts it to the front of
the fan-out before the cap is applied (ensureSlug in chat-tree-data.ts), so
the agent you asked for is never the one dropped. Or use
conversation search, which is server-side and
spans up to 400 agents.
What the general case still needs is a screen affordance, not a doc line.
A conversation that was “not asked for” is indistinguishable from one that
does not exist, and so, today, is one whose fetch failed. Until the column
renders both as unknown rather than absent — the data to do it is already
in threadErrors — this page is a workaround and not a fix.
Workspace conversation search stops at 400 agents
A workspace-scoped search —⌘K, or crewship conversation search without
--agent — resolves the agents it may read from the workspace on your
session and passes their ids to the search backend. Those ids become bound
query parameters, and SQLite’s default variable ceiling is 999, so the set is
capped at 400, taking the most recently created agents.
A workspace past 400 agents therefore searches its newest 400 and silently
omits the rest. Nothing in the response body marks the scope as truncated and
nothing is logged. The bound is real and deliberate — a query that failed
outright at agent 1000 would be worse — but a scope of workspace in the
response currently means “workspace, or the first 400 of it”, and a caller
cannot tell which.
Narrow with --agent (or the palette’s per-agent scope) when you need a
specific older agent searched for certain.
Attachments
The 25 MB cap is enforced twice — in the browser before anything is uploaded, and again by the API — and both say so. The browser names the file in a toast; the API answers400 with invalid multipart form or file too large (max 25MB).
The other attachment bound is not a size. The file lands in the agent’s
/output tree, which a provisioned crew owns as uid 1001, so the server
writes it through the running crew container. A stopped crew returns
409 with the remedy in the message. Start the crew and retry; nothing
partial is left behind and no metadata row is recorded for an upload that did
not land.
One thing that is not bounded: a chat attachment’s blob is not
content-addressed and sits outside the reclaim machinery on purpose
(internal/api/attachments_gc.go says so), so uploads are not deduplicated and
are not swept. The fully-featured comparison surface is
issue attachments.
Suggested prompts and ask forms
Every cap here is a refusal, not a truncation, and every refusal names the offending item: the prompt by its position in the list, the form by its id, the placeholder by its name. Nothing is written when a definition is refused. Seecrewship agent update and
Update Agent for the full contract.
The two exceptions are at render time rather than at save time: one
substituted answer over 2000 characters, and a finished message over 32000,
are both cut without a warning. A valid form cannot reach the message cap
(six fields at 2000 plus a 2000-character template is under it), so in
practice this is the value cap on a long textarea answer. Both renderers —
Go and TypeScript — are pinned to one golden fixture, so the CLI’s
crewship agent ask-preview shows exactly what a truncated send would carry.
Session titles
A session names itself from its first message, cut to 60 characters at a word boundary, with an ellipsis only when something was actually cut. That is a client-side derivation and the visible result is the title itself. A title you set by hand — from the console, or withcrewship chat rename — is normalised server-side and capped at 200
characters. Over the cap is a 400, never a silent truncation, so a long
title is refused rather than quietly shortened.
See also
- Chat Sessions — the surface these limits apply to
- Conversation Search — scopes and isolation
crewship chat— attach, rename, list from the CLIcrewship agent— suggested prompts and ask forms