Skip to main content
Chat has a dozen bounds in it. Most refuse loudly and name what was wrong; two truncate quietly and one drops data from the navigation with nothing on screen to say so. This page is the single list, written so you can tell those cases apart before a user does. The rule this page exists to enforce: no silent caps — log what was dropped. Where a limit does not yet meet that rule, it says so here rather than being described as if it did.

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 into chats — 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. direct is 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 as direct and 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 only mode and origin, which are written by the code path that created the row.
What the limit still costs, with kinds in place: the eleventh-oldest conversation of the scope you are viewing is not fetched. That is the bound behaving as a page size, which is what it was always meant to be. The console’s own separation follows from this — the conversations column’s Show section (Direct / Routines / Issues) is the 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: one GET /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 answers 400 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. See crewship 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 with crewship 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