Skip to main content
An agent is an AI coding agent with a configurable role, CLI adapter, skills, and credentials. Agents belonging to the same crew share one Linux container. These endpoints cover the full agent lifecycle: creating and configuring permanent agents, hiring short-lived ephemeral “contractors”, managing their skills and credentials, driving chats and runs, inspecting workspace files, and tuning the persona and self-learning layers that shape each agent’s behavior.
All /api/v1/agents/* endpoints require authentication and workspace context. Workspace context may be supplied as workspace_id, a {workspaceId} path value, or the X-Workspace-ID header; the chat-message proxy (/api/v1/chats/{chatId}/messages) is the exception — it resolves the workspace from the chat row and takes no workspace context parameter.

Endpoints

Status & Metrics

Lightweight, read-only aggregates that power toolbar and dashboard widgets.

Crews Status

Returns lightweight agent counts by status for toolbar/dashboard widgets. Auth: Authenticated session or CLI token with resolved workspace context; the router applies the workspace-scoped authenticated middleware. Request: No body. Supply workspace context with X-Workspace-ID (or the equivalent workspace context accepted by the API). Response: 200 OK
Status: 400 for invalid workspace context, 401 when unauthenticated, 403 when the caller cannot access the workspace, and 500 when the status query fails.

Contract: GET /api/v1/agents/crews-status

Auth: Authenticated request with workspace context. Request: no body; the workspace is supplied through the workspace context/header. Response: 200 OK with the status-count object above. Status: 400, 401, 403, or 500 as described above. The agent status buckets (total, running, error, idle) are always present — zero counts are not omitted. Any status that is not RUNNING or ERROR (including IDLE and PENDING_REVIEW) folds into the idle bucket; there is no stopped bucket. The queued field counts admission-queue assignments (not agents) currently in the QUEUED state — independent of the agent buckets, and 0 on servers that pre-date the queue migration (internal/api/agents.go:111-151).

Agent Load

Returns agent load metrics across the workspace. Auth: Authenticated session or CLI token with resolved workspace context. Request: No body. Supply workspace context with X-Workspace-ID (or the equivalent workspace context accepted by the API). Response: 200 OK — an array (never null) of entries with agent_id, agent_name, agent_slug, agent_status, active_tasks, pending_tasks, completed_today, tokens_used_today, and token_budget. Active tasks include IN_PROGRESS; pending tasks include PENDING and BLOCKED; token usage and completed counts are calculated over the current UTC day/24-hour reporting window used by the handler. Status: 400 for invalid workspace context, 401 when unauthenticated, and 403 when the caller cannot access the workspace.

Model Discovery

Enumerate the models an LLM provider can serve, so an operator (or an agent building a POST/PATCH /api/v1/agents request) doesn’t have to guess a model identifier. Discovery is live when possible, curated when not (internal/api/models.go:84): if the workspace has an active API_KEY credential for the provider, Crewship queries the provider’s own models API; otherwise it returns a hand-maintained curated fallback. The source field reports which path was taken. See the Model discovery guide for the CLI equivalent (crewship model list) and the llm_model validation this same resolver drives on agent update.

List Models

Auth: Any authenticated workspace member (authed + workspace context; the active provider credential is read from the resolved workspace) (internal/api/router_crews.go:230). Query Parameters: Response: 200 OK
OLLAMA has no curated fallback. Its model set is whatever the local daemon has pulled, so there is no sensible static list. When the daemon can’t be reached the endpoint returns 502 rather than an empty list, so “daemon unreachable” is never mistaken for “no models installed” (internal/api/models.go:103-107). GOOGLE has no live lister yet and always resolves to its curated set (internal/api/models.go:78).

Lifecycle & CRUD

Create, read, update, and soft-delete permanent agents.

List Agents

Query Parameters: Response: 200 OK
mcp_config_json and created_by_user_id carry omitempty — they are dropped from the JSON entirely when null/empty rather than serialized as null.

Response Fields


Create Agent

Auth: OWNER, ADMIN, or MANAGER role Request Body:
Agent Roles: CLI Adapters: CLAUDE_CODE, CODEX_CLI, GEMINI_CLI, OPENCODE, CURSOR_CLI, FACTORY_DROID Tool Profiles: MINIMAL, CODING, FULL Response: 201 Created — returns the created agent object.

Get Agent

Response: 200 OK — full agent object.

Update Agent

Partial update — only provided fields are changed. Auth: Per-agent edit gate (canEditAgent in internal/api/agents_update.go:27, defined at internal/api/rbac.go:151). OWNER / ADMIN may edit any agent; MANAGER may edit only agents they created or agents in crews where they hold per-crew ADMIN/OWNER; MEMBER / VIEWER are refused. Request Body: Same fields as Create, all optional. status is not mutable through this endpoint. Additionally supports: Suggested prompts. suggested_prompts holds the questions this agent offers as buttons under an empty conversation, one per line — at most 8 lines, at most 120 characters each. The server normalises on write: CRLF becomes LF, each line is trimmed, blank lines are dropped, and a value that ends up empty is stored as null. An agent with null here is not misconfigured; it simply shows the built-in defaults, which is what every agent did before the field existed. Rejections name the offending line — "at most 8 prompts are allowed (got 9)", "prompt 3 exceeds 120 characters (it has 154)" — so a caller can fix the entry rather than the whole list. Set it from the console under an agent’s Config -> Chat suggestions, or with crewship agent update <agent> --suggested-prompts @questions.txt. Ask forms. ask_forms holds the questionnaires this agent offers: a chip that opens a short form, whose answers are rendered into an ordinary user message and sent. The value is a JSON array given as a string — the column is TEXT, the console edits it in a textarea and the CLI reads it from a file, and accepting a nested array as well would put two encodings of one value on one endpoint.
id is a slug and must be unique; label is the chip’s text (max 48 characters); attachment is none | optional | required and defaults to none; template is at most 2000 characters. Field types are text, textarea, number, money, date, month, select, multiselect, checkbox, file and photoan unrecognised type is accepted and falls back to a text input, which is what lets a new type ship without a coordinated frontend release. select and multiselect must carry options. A JSON Schema for authoring lives at schemas/ask-form.v1.json. Money fields answer to two placeholders. A money field named amount renders as {{amount}} for the number and {{amount_currency}} for the chosen currency. The second name is derived so that two money fields on one form cannot fight over a single {{currency}}. Template rendering. {{field}} substitution only — no conditionals, no loops, no expressions. Three rules decide what gets sent:
  1. Every placeholder must name a field on its own form, and this is checked when the form is saved, not when it is rendered. The author finds out while authoring; the person talking to the agent never meets a broken template.
  2. An empty optional value drops the whole line it sits on, static label and all, unless another placeholder on that line produced something. So a receipt with no category sends no Category: line rather than a dangling one. This is the only rule that is not obvious from reading a template.
  3. Values are inserted verbatim — the result is a user message, so nothing is markdown-escaped — but control characters are stripped, each value is capped at 2000 characters and the finished message at 32000. file and photo values render as the agent-visible path attachments/<chatId>/<name>, one per line when there are several.
Rejections name the form and the placeholder, because the caller is mid-edit: form "receipt": template names {{suplier}}, which is not a field on that form, form "receipt": has no fields, two forms share the id "receipt", at most 4 forms are allowed per agent (got 5). Nothing is written when a definition is refused. A value that normalises to nothing — "", whitespace, [], or null — clears the column. The stored value is canonical: parsed, validated and re-encoded with defaults spelled out and a stable key order, so what a caller reads back is stable rather than showing a diff nobody made. Set it from the console under an agent’s Config -> Ask forms, or with crewship agent update <agent> --ask-forms @forms.json, and render one without a browser with crewship agent ask-preview <agent> <form-id> --var k=v. Response: 200 OK — updated agent object.

Delete Agent

Soft-deletes the agent (sets deleted_at). Request: No body; agentId is a required path parameter and workspace context scopes the lookup.
This is a destructive operation. Same per-agent edit gate as Update (canEditAgent in internal/api/agents_query.go:266, defined at internal/api/rbac.go:151). OWNER / ADMIN delete any agent; MANAGER deletes only agents they created or agents in crews where they hold per-crew ADMIN/OWNER.
Response: 200 OK

Rotate Webhook Secret

Uses the same per-agent edit gate as Update. The new signing secret is returned exactly once; Crewship stores only its protected form and does not provide a read endpoint. Response: 200 OK

Hire / Rehire / Approve

Ephemeral agents (“contractors”) are short-lived agents spawned against a crew with a TTL. When the TTL elapses the agent becomes a ghost (the row is preserved for audit but no longer counts against the crew’s quota). The hire flow is gated by the crew’s autonomy policy (strict / guided / trusted / full) and bounded by the crew’s max_ephemeral_agents quota (ghosts excluded).
All three endpoints require OWNER, ADMIN, or MANAGER role (canRole(role, "create")).

Hire Agent

Spawns a new ephemeral agent under a crew. The crew’s autonomy policy decides the outcome: Auth: OWNER, ADMIN, or MANAGER role Request Body:
Response: 201 Created (live) or 202 Accepted (pending review).

Credential assignment

On a successful hire (201/202), Crewship auto-assigns the available workspace Anthropic credentials (API_KEY / AI_CLI_TOKEN, first-created wins) to the new agent so it can authenticate on first run. This is best-effort and runs after the agent row is committed: assignment failures are journaled as credential.auto_assign_failed and never fail the hire, and a workspace with no Anthropic credential journals credential.auto_assign_empty. Assign manually later via POST /api/v1/agents/{agentId}/credentials if needed.

Rehire Agent

Resurrects an expired ephemeral (“ghost”): clears expired_at, pushes expires_at forward by the new TTL, and appends a new reason line to the hire_reason history. The container is not rebuilt here — the chatbridge auto-provisions a fresh one on the next message. Rehiring a still-live ephemeral (extending its TTL before it ghosts) is free and does not consume an extra quota slot. Auth: OWNER, ADMIN, or MANAGER role Request Body:
Response: 200 OK — same shape as the Hire response, echoing the agent’s persisted status (rehire does not change it) and the updated expires_at / hire_reason.

Approve Hire

The guided-autonomy approval step. Flips a PENDING_REVIEW ephemeral to IDLE (an atomic conditional UPDATE, so two concurrent approvals can’t both win), resolves the blocking inbox waitpoint addressed to the agent, and writes an agent.hire_approved audit entry. The chatbridge refuses to start a PENDING_REVIEW agent, so this is what actually releases the gate. Auth: OWNER, ADMIN, or MANAGER role Request Body: empty. Response: 200 OK

Agent Persona

The persona layer is per-agent PERSONA.md markdown that the orchestrator injects into every system prompt. Resolution is layered: the agent layer (if a file exists on disk) wins; otherwise the crew default layer; otherwise the synthesized default built from the agent’s role + role title. Direct writes are operator-only; agents edit only via the suggest endpoint, which the policy resolver gates per crew-autonomy level (see Autonomy & self-learning).

Get Persona

Returns the resolved persona — agent layer if a file is present, crew default otherwise, synthesised default if neither layer is configured. Auth: Authenticated workspace member. Request: No body; agentId is required and workspace context scopes the agent lookup. Response: 200 OK
Status: 400 for invalid request/workspace context, 401 when unauthenticated, 403 when access is denied, and 500 when persona storage cannot be read.

Set Persona

Operator-only direct write of the agent layer. Records a row in memory_versions so Get Persona History can replay the chain. Request:
Response: 200 OK
Agents cannot use this endpoint to mutate their own persona — ActionPersonaDirectWrite is DecisionRejected across every autonomy level in Phase 1. Agent edits flow exclusively through Suggest Persona.

Reset Persona

Removes the agent layer file. The next GET falls through to the crew layer (or the synthesised default).
Auth: Authenticated caller with the operator edit permission used by the persona mutation routes. Request: No body; agentId is required. Response: 204 No Content Status: 401 unauthenticated, 403 when the caller lacks the mutation permission, and 500 when the persona cannot be reset.

Get Persona History

Lists rows from memory_versions filtered to this agent’s PERSONA.md path. Pairs with GET /api/v1/admin/memory/versions/{id}/content for content drill-down — the history endpoint deliberately omits content to keep responses small. Query Parameters: Response: 200 OK
parent_sha is omitted on the first version (when the chain starts).

Suggest Persona

Agent-initiated persona proposal. The crew’s autonomy policy decides the outcome: ActionPersonaDirectWrite is rejected across every autonomy level — this endpoint is the only path an agent can use to write a persona. Request:
Response: 200 OK (proposal queued or applied) / 403 (policy rejected)
There is no inbox_id in this response — the proposal is recorded as an audit_logs row (persona.suggest_pending), not returned to the caller.

Agent Self-Learning Posture

Per-agent flag controlling whether ALLOW decisions from the Keeper evaluators auto-apply (enabled = true) or queue for operator approval in the inbox (enabled = false). Orthogonal to the crew’s autonomy_level: the per-action policy gate (policy.DecideAction) is the upstream authority — this flag only decides what happens to already-ALLOWed decisions. See Autonomy & self-learning.

Get Self-Learning

Returns the current flag plus the audit triple (who flipped it, when, why). Any authenticated workspace member can read — the value is non-secret diagnostic state. Auth: Authenticated workspace member. Request: no body; agentId is a required path parameter. Status: 400 for invalid request context, 401 when unauthenticated, 403 when access is denied, and 500 for a read failure. Response: 200 OK

Set Self-Learning

Flip the flag. Requires OWNER / ADMIN (canRole(role, "manage")) — self-learning weakens the inbox-approval invariant, so the operator who turns it on must be senior enough to own the consequences. Request:
Response: 200 OK — same shape as GET, reflecting the new state and the freshly-recorded audit triple.

Agent Inbox Summary

Consolidated “what’s waiting on this agent” payload used by the Crews preview panel — one round-trip instead of four parallel fetches across approvals, assignments, escalations, and peer messages. UI is the primary consumer; operators can poll the endpoint for an at-a-glance load check. Auth: Authenticated workspace member. Request: no body; agentId is a required path parameter and workspace context scopes the lookup. Response: 200 OK

Agent Peer Cards

Per-(agent, user) markdown notes produced by the PeerCardSync routine — the agent’s own “what I know about this person” file. These endpoints are operator-facing: the cards live on disk under {outputBase}/crews/{crewID}/agents/{slug}/.memory/peers/, and every read/write/delete writes a peer_card_audit row for GDPR SAR coverage. The user-facing surface (view-mine / opt-out / delete-mine) lives under /api/v1/users/me/peer-cards and shares the same disk + DB primitives — these agent-flavor endpoints exist so an operator can clean up cards on behalf of a user (compliance ticket, leaver workflow) or inspect what an agent has been writing about people. The {userId} path parameter is the raw user_id; the server derives the user_slug via memory.UserSlug so the URL stays debuggable.

List Agent Peers

Returns the agent’s peer-card index (no card content — fetch via the single-card endpoint below). Workspace-scoped; agents in other workspaces return 404. Auth: Authenticated workspace member. Request: No body; agentId is required and workspace context scopes the lookup. Response: 200 OK

Get Agent Peer Card

Returns one card’s full content (markdown). Writes a read row to peer_card_audit. Auth: Authenticated workspace member. Request: No body; agentId and userId are required path parameters and workspace context scopes the lookup. Response: 200 OK

Delete Agent Peer Card

Removes the card from disk and the peer_cards row, then writes a delete audit row. Idempotent — deleting a non-existent card returns 204 rather than 404 so an operator script can retry safely.
Auth: Authenticated caller with the self/mutation permission used by this route. Request: No body; agentId and userId are required path parameters. Response: 204 No Content
The GDPR cascade endpoint DELETE /api/v1/admin/users/{userId}/data deletes peer cards across all agents in the workspace for a single user — that’s the right path for a full SAR. This per-agent endpoint is for the narrower “clean up just this agent’s notes about this user” case.
These workspace-admin routes are listed here because the GDPR cross-reference above uses the same admin-user path prefix.

List workspace users

Auth: Authenticated workspace OWNER or ADMIN (manage role). Request / parameters: No request body. Workspace context is supplied by the authenticated request; there are no path or query parameters. Response: 200 OK with a JSON array of workspace members and their user, workspace, and role fields.

Export one user’s data

Auth: Authenticated workspace OWNER or ADMIN (manage role). Request / parameters: No request body. userId is the required path parameter for the data subject; the request is scoped to the authenticated workspace. Response: 200 OK with the GDPR export bundle for that user, including peer cards, memory versions, inbox items, user models, and an audit action_id.

Agent avatars

An agent’s avatar is drawn from its avatar_seed and avatar_style by a generator that runs in the browser. That makes the rendered face a function of the installed generator version: upgrading the avatar library redraws every existing agent, and the roster your team recognises changes overnight. To stop that, an agent can carry a stored render. Once stored, that exact image is served instead of being redrawn, so the agent’s face survives generator upgrades. Both states are normal: Use the string verbatim. It already carries both ?v= and workspace_id, the latter because this route resolves its workspace the same way every other endpoint does, and an <img> tag cannot send the X-Workspace-ID header. Rebuilding the URL by hand and dropping that parameter yields a 400.
The server never draws an avatar (the generator is JavaScript-only). The client renders the image and stores it — at creation, or the first time someone with edit rights views an agent that has none. Agents created through the CLI or crew templates therefore start with avatar_url: null and get filled in later.

Get stored avatar

Returns image/svg+xml. Readable by any workspace member. workspace_id is required — it scopes the lookup to your workspace, so one workspace can never read another’s avatars. Returns 404 when the agent has no stored render — which is not an error, it just means the client should generate from the seed. The ?v= parameter carries a content hash and is ignored by the server; it exists so the response can be cached immutably and still refresh when the image actually changes. Auth: Authenticated workspace member. Request: No body. agentId is required; v is an optional cache-busting query value and workspace context is required. Response: 200 OK with an SVG body (image/svg+xml). Status: 400 for invalid request/workspace context, 401 when unauthenticated, 403 when the caller cannot access the workspace, and 404 when the agent or stored avatar is not found. Responses are served with Content-Security-Policy: default-src 'none'; sandbox and X-Content-Type-Options: nosniff, so the SVG cannot load subresources or execute script even if opened directly.

Store an avatar

Requires the same per-agent edit rights as PATCH /api/v1/agents/{agentId}. Auth: Authenticated caller with the same per-agent edit rights as PATCH /api/v1/agents/{agentId}. Request: JSON object with required string field svg. Response: 200 OK with the stored avatar representation. The SVG is validated against an allowlist of inert drawing, gradient and metadata elements. Anything capable of loading or executing something — <script>, <foreignObject>, <use>, <image>, any href, any on* handler, any external url(…) — is rejected with 400. Maximum 64 KiB. Storing is write-once. An agent that already has a stored avatar returns 409; clear it first to replace it. Without that rule, an endpoint that fires automatically on page view would double as a way to silently swap a teammate’s agent portrait.

Clear a stored avatar

Returns 204. The agent reverts to generate-from-seed — which also means the next generator upgrade will change its face again. Returns 404 for an agent that does not exist in this workspace, and 403 without edit rights. Auth: Authenticated caller with per-agent edit rights. Request: No body; agentId is required. Workspace context is resolved by the authenticated route. Response: 204 No Content on success. Status: 401 unauthenticated, 403 insufficient edit rights, and 404 when the agent is not found.

When a stored render is dropped automatically

A stored render depicts one specific (avatar_seed, avatar_style) pair, so it is discarded whenever something changes what the agent is supposed to look like:
The comparison is on values, not on which keys the request contains. Clients that resubmit the whole agent on every save — the dashboard’s settings page does — would otherwise discard the stored render on every unrelated edit.

Agent Skills

Assign, list, and remove the skills available to an agent.

List Skills

Response: 200 OK — array of skill assignment objects.

Add Skill

Auth: OWNER, ADMIN, or MANAGER role (canRole(role, "create")) Request Body:
Response: 201 Created{ "id": "<assignment_id>" } on a fresh assign. The assign is idempotent: re-assigning an already-installed skill returns 200 OK with { "id": "<existing_id>", "already_assigned": true } rather than a 409.

Remove Skill

Response: 204 No Content

Agent Credentials

Assign, list, and remove the credentials injected into an agent’s container.

List Credentials

Response: 200 OK — array of the credentials this agent receives. This answers “what does this agent get?”, not “what rows exist in the assignment table”. A credential linked to the agent’s crew is delivered to that agent and therefore appears here, even though no assignment row exists for it. An explicit assignment always wins over a crew link for the same credential: it appears once, as explicit, carrying the env var name, priority and lease that were chosen for it. Each object also carries the grant’s lease state (see Short-lived leases). Note that lease_source and grant_source answer different questions — the first says why a grant expires, the second says where it came from:

Assign Credential

Auth: OWNER or ADMIN role (canRole(role, "manage") in internal/api/agent_credentials.go:89) Request Body:
Response: 201 Created

Remove Credential

The path segment is the assignment ID (agent_credentials.id), not the credential id. Auth: OWNER or ADMIN role (canRole(role, "manage") in internal/api/agent_credentials.go:146) Response: 200 OK

Agent Chats & Runs

List and create interactive chat sessions, and review execution history.

List Chats

Response: 200 OK — array of chat session objects. Auth: Authenticated workspace member. Request: No body; agentId is required and workspace context scopes the list. Status: 400 for invalid request/workspace context, 401 when unauthenticated, and 403 when the caller cannot access the workspace.

Create Chat

Creates a new interactive chat session with the agent. Response: 201 Created The optional request body is { "session_id": "…", "origin": "UI" }. origin may be UI, CLI, WEBHOOK, CRON, or AGENT; unknown values are stored as empty. The response is { "id": "<chatId>" }. Auth: Authenticated caller permitted to create a chat for the agent. Request: Optional JSON object with session_id and origin. origin is one of UI, CLI, WEBHOOK, CRON, or AGENT. Status: 200 or 201 with the created chat id; 400 invalid JSON or origin, 401 unauthenticated, 403 denied, 404 agent not found, and 409 when the requested session conflicts.

Mark Chat Read

Advances the caller’s read cursor and clears the paired agent-replied inbox notification. Returns 200 OK with { "success": true }; a chat that is not nested under the supplied agent or workspace returns 404. Auth: Authenticated chat participant/caller with access to the chat. Request: Empty JSON object ({}); agentId and chatId are required path parameters. Response: 200 OK with the chat read-cursor payload (chat_id and last_read_at). Status: 401 unauthenticated, 403 denied, and 404 when the chat is not nested under the supplied agent and workspace.

Rename Chat

Sets chats.title — the name the session list, the chat sidebar and the agent-replied notification display. A session with no title reads “Untitled session” everywhere, so this is what makes a list of conversations navigable. Auth: The chat creator may rename their own chat; an agent editor may rename any chat for that agent — the same gate as Delete Chat. Request: { "title": "Refactor the queue worker" }. title is required and must be a string; an absent key, null, a non-string and a title that is empty after normalisation are all 400. Normalisation (server-side, before storage): whitespace runs — including newlines and tabs — collapse to a single space, because a title is one line by definition; control characters and invisible formatting characters (zero-width padding, the right-to-left override used for label spoofing) are stripped, with the ZERO WIDTH JOINER exempted so emoji sequences survive; the result is trimmed and capped at 200 characters. The cap counts characters, not bytes, so a Czech or Japanese title gets the same length as an English one. Over the cap is 400, never a silent truncation. The stored title is text, not HTML: markup is preserved verbatim (<Draft> plan stays <Draft> plan), and every consumer is responsible for escaping it when rendering. Response: 200 OK with the updated chat row — the same object shape one element of List Chats has, including unread_count, so a client can replace the row it already holds without refetching the list. Renaming does not touch last_activity_at: fixing a name must not reorder the list. Status: 400 invalid or empty/over-long title, 401 unauthenticated, 403 when the caller is neither the creator nor an agent editor, 404 when the chat is not nested under the supplied agent and workspace (cross-tenant included), and 500 on a storage failure.

Delete Chat

The chat creator may delete their own chat; an agent editor may delete any chat for that agent. Chats with assignments cannot be deleted (409). A successful deletion returns 204 No Content. Auth: The chat creator may delete their own chat; an agent editor may delete any chat for that agent. Request: No body; agentId and chatId are required path parameters. Response: 204 No Content on deletion (the handler may also return the documented JSON deletion result). Status: 401 unauthenticated, 403 denied, 404 for an unknown or mis-nested chat, 409 when assignments prevent deletion, and 500 for cleanup failure.

Chat Attachments

Uploads an attachment for the chat through the sidecar. The multipart/form data field is file; the agent and chat must belong to the caller’s workspace. The endpoint returns the sidecar’s attachment metadata and uses 400 for an invalid upload, 404 for an unknown or mis-nested chat, and 502 when the sidecar cannot be reached. The blob is written to <crewID>/<agentSlug>/attachments/<chatId>/<attachmentId>/<filename>, which the agent sees at /output/<agentSlug>/attachments/<chatId>/<attachmentId>/<filename>. A provisioned crew owns that tree as uid 1001, so the server cannot create the directory host-side; the write is replayed through the running crew container as that uid. A stopped crew therefore returns 409, with the remedy in the message. One upload is one identity at one location. The attachment id segment is what makes that true. Uploading two different files under the same name — say evidence.pdf twice — produces two attachments, two directories and two blobs, each resolving to its own recorded checksum; neither overwrites the other. Re-uploading the same bytes under the same name in the same chat is a retry, not a second attachment: it resolves to the attachment that already exists and returns its path. Chat attachment blobs are deliberately not content-addressed, because the path is what the agent is told to open. They therefore live outside <root>/attachments/ and outside the content-addressed reclaim sweep; the delete endpoint below is how their bytes are recovered. Metadata is written before the bytes, not after. The row is inserted first in an unpublished state, the blob is written second, and the row is promoted last, so 201 means both exist: a success response can never mean “the bytes landed and nobody recorded it”. Every failure leaves an unpublished row, which is never listed and never returned, and which a background collector removes along with any bytes it names once it is more than an hour old (the sweep runs hourly, so within about two hours). Attachments uploaded before this scheme keep their original two-segment path (attachments/<chatId>/<filename>) and keep working unchanged — the stored key is the authority on where an attachment lives, so they list, resolve and delete exactly like new ones. Errors from the storage layer are forwarded as a single sentence prefixed with failed to save attachment: , not as a nested JSON document. Auth: Authenticated caller with create/write access to the agent chat. Request: multipart/form-data with required binary file field; no JSON body. Response: 201 Created with {filename, size, path, agent_path}. path is the agent-relative attachments/<chatId>/<attachmentId>/<filename>; agent_path is the same location as an absolute in-container path. Status: 400 invalid upload, 401 unauthenticated, 403 denied or the chat is not this agent’s, 404 unknown agent/chat, 409 the crew container is not running (start the crew and retry), 413 too large to write through the crew container, 503 no container runtime is configured, and 502 sidecar failure.

List Chat Attachments

Every published attachment on one chat, newest first. Unpublished reservations (an upload that did not finish) are never included. Auth: Any authenticated member of the workspace that owns the chat. Response: 200 OK with an array. Each element carries id, workspace_id, owner_type (chat), owner_id (the chat id), filename, content_type, size_bytes, sha256, uploaded_by_user_id, uploaded_by_agent_id, uploaded_by_name, created_at, path and agent_path. id is the durable identity — it is what the delete below takes, and it is the segment inside path. Status: 400 when a path id is missing, 401 unauthenticated, 404 for an unknown, mis-nested or cross-tenant chat (the three are indistinguishable by design), 500 on a read failure.

Delete Chat Attachment

Removes the attachment: the stored file first, then the metadata row. Idempotent. Deleting an attachment that is already gone answers 204 — what the caller asked for is already true. Cross-tenant access is refused one step earlier: an attachment in another workspace is reachable only through a chat in that workspace, and that chat is a 404 here. If the unlink fails (most often a stopped crew, which owns the tree) the row is kept and the error is forwarded as failed to delete attachment: …; retrying the same call completes the deletion. The order is deliberate — a row that outlives its bytes is visible and repairable, whereas bytes that outlive their row are unreachable for ever, since no sweep walks this tree. Auth: Authenticated caller with create/write access to the agent chat. Request: No body. Response: 204 No Content. Status: 400 when a path id is missing, 401 unauthenticated, 403 denied, 404 for an unknown, mis-nested or cross-tenant chat, 409/5xx forwarded from the storage layer when the bytes cannot be removed, 502 sidecar failure.

Credential Bindings

Lists the agent’s MCP server bindings. Each item includes id, agent_id, mcp_server_id, mcp_server_scope (workspace or crew), optional credential/type/header fields, enabled, optional config_override_json, timestamps, server names, and optional credential_name. Returns 404 when the agent is not in the workspace.

Agent Integrations

These are the agent MCP-binding routes. POST requires mcp_server_id and mcp_server_scope (workspace or crew); writes require MANAGER+ and reads require workspace membership. The resolved route returns the effective workspace → crew → agent integration cascade. Auth: Authenticated workspace member. Request: No body; agentId is required and workspace context scopes the resolution. Response: 200 OK with an integrations array containing the effective workspace/crew configuration. Status: 400 for invalid request/workspace context, 401 when unauthenticated, 403 when denied, and 404 when the agent is not found.

Agent Notification Channels

Returns { "channels": [...] }, where each channel contains id, type, optional provider, and enabled. It is readable by any workspace member; unknown or cross-workspace agents return 404. Auth: Any authenticated workspace member. Request: No body; agentId is required and workspace context is required. Response: 200 OK with { "channels": [...] }. Status: 400 when agentId is missing or request context is invalid, 401 when workspace context is missing, 403 when access is denied, and 500 when the channel lookup fails.

Agent Runs

List execution history for an agent. Response: 200 OK — array of run objects.

Agent Proxy Endpoints

These endpoints proxy requests through the crewshipd sidecar to the agent’s container.
POST /api/v1/agents/{agentId}/stop halts a running agent.

Container Files

Lists files inside the agent’s running container (as opposed to /files, which lists the persistent workspace mount). Proxied through crewshipd; the request is rejected before the IPC hop if the agent is not assigned to a crew. (internal/api/proxy_files.go:350) Auth: Session or CLI token + workspace membership with at least read-tier role (canRole(role, "read")) Query Parameters: Response: 200 OK — JSON array of file entries (unwrapped from the IPC layer’s {files: [...]} envelope). Returns [] when the sidecar response is unparseable or empty.

Git Log

Fetches recent git commits from the agent’s container workspace. Proxied through crewshipd; the agent’s slug is forwarded as agent_slug so the sidecar can scope the log to that agent’s directory inside the shared crew container. (internal/api/proxy.go:312) Auth: Session or CLI token + workspace membership with at least read-tier role (canRole(role, "read")) Response: 200 OK — JSON array of commit entries (unwrapped from the IPC layer’s {commits: [...]} envelope). Returns [] when the sidecar response is unparseable or empty.

Chat Messages

This page links the agent lifecycle to the crew that owns it. The canonical crew reference has the full schemas; these endpoint-local notes preserve the request contract for the route references used on this page.

Get Crew

Auth: Session or CLI token plus workspace membership. Request: no body; crewId is required and workspace_id supplies workspace context. Response: 200 OK with the full crew object. Status: 404 when the crew is missing or outside the workspace.

Delete Crew

Auth: Session or CLI token plus OWNER or ADMIN role. Request: no body; crewId is required and workspace_id supplies workspace context. Response: 200 OK with { "success": true }. Status: 403 for an insufficient role and 404 when the crew is not found.
Get messages for a specific chat session. Proxied through crewshipd. Supports ?offset= and ?limit= (default 50, max 500). Auth: Session or CLI token with read-tier role, plus membership of the chat’s workspace. The workspace is resolved from the chat row (no workspace_id query param needed); the caller must appear in workspace_members for that workspace or the call returns 403. (internal/api/proxy.go:258) Response: 200 OK — proxied message payload. A chat that doesn’t exist yet (new session before its first message) returns { "messages": [] } rather than 404.