Skip to main content
Issues are crew-scoped tracker items: each carries a human-readable identifier (e.g. ENG-42), a status flow, and lifecycle operations for agent execution. Beyond CRUD, the API covers comments, relations, an activity log, workspace labels, and projects that group issues with tracking metadata. Under the hood, issues are stored in the missions table with mission_type = 'issue' — they share infrastructure with missions but keep their own status flow, identifiers, and lifecycle.
All issue endpoints require authentication and workspace context.

Endpoints


Issues CRUD

Create, read, update, and delete issues. Most write operations require OWNER, ADMIN, or MANAGER role and emit a WebSocket event on workspace:{workspaceId}.

List Issues (Workspace)

Returns issues across the workspace with filtering, sorting, and pagination. Query Parameters: The body stays a bare array. The total the filter matched — before limit/offset — is in the X-Total-Count response header, with X-Limit and X-Offset echoing the values applied. An agent assignee carries assignee_slug next to assignee_name. Response: 200 OK
Pagination (#2286): the body is still the bare array above — unchanged for every existing client — but the response now also carries two headers:A caller that only reads the array still gets identical bytes; a caller that wants to know whether a 100-row page was the whole story reads the headers instead of guessing. crewship issue list prints a “Showing N of TOTAL issues” footer from them and takes --offset to page further; the issues board (hooks/use-issues-list.ts) uses them to drive its own “Load more”.
Error degradation contract (#2285): the issues board classifies a failed request to this endpoint instead of rendering it as an empty workspace. A 403 is shown separately from a 5xx/network failure — it’s reported as “the request was refused”, naming a scoped agent/CLI token as the likely cause (AuthKindCLIToken in internal/api/middleware.go is the credential “an agent, CI job, or script holds”; a 403 here is what an agent/CLI token whose scope doesn’t cover issues, or a transient token problem, looks like) alongside a retry action, rather than presenting a board with zero issues. See hooks/use-issues-list.ts for the implementation and the full reasoning.
execution.stage is the execution’s place in the work→Lead-review→correction loop:Only one execution per issue can be working or reviewing at a time. The narrative is in the guide: Work, hand-offs and Lead review.

Get Issue (Workspace-scoped)

Get an issue by its identifier (e.g., ENG-42) across the entire workspace. No crew ID needed. Request: identifier is required; workspace_id selects the workspace. No request body. Response: 200 OK — full issue object with labels and comment count.

Create Issue

Auth: OWNER, ADMIN, or MANAGER role The crew must have a LEAD agent. The issue identifier is auto-generated from the crew’s issue_prefix (or first 3 characters of the slug) and an auto-incrementing counter. Request Body:
Response: 201 Created — issue object with generated number and identifier. WebSocket event: issue.created on workspace:{workspaceId}

Get Issue (Crew-scoped)

Get an issue by its identifier within a specific crew. Includes labels and comment count. Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Response: 200 OK — full issue object.

Update Issue

Auth: OWNER, ADMIN, or MANAGER role Request Body: All fields optional. Owner and delegate are independent (#2297, invariant “human owner, agent delegate”): a user write never touches delegate_agent_id and an agent write never touches owner_user_id. The only way to empty a slot is the explicit unassign, which empties both. The legacy assignee_type/assignee_id pair is always written alongside as a compatibility projection of the last write. The CLI: crewship issue update <id> --assignee <email|agent> --assignee-type user|agent. Status Transitions: When status changes to DONE, CANCELLED, or DUPLICATE, completed_at is automatically set. Significant changes (status, assignee, priority) are logged as activity entries.
Work package B11 (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md §10.4, #2368): the terminal-children rule.
The terminal-children rule. A transition to DONE or REVIEW is refused with a 409 while the issue has a non-terminal sub-issue (parent_issue_id pointing at this issue, status not yet DONE/CANCELLED/DUPLICATE) or a non-terminal plan task (mission_tasks row, status not yet COMPLETED/FAILED/CANCELLED/SKIPPED). The 409 body names every blocker. Add ?force=true to the query string to proceed anyway — the transition then commits and the status-change activity/event entry (both List Activity and List Events) records forced: true and the list of children that were still open, alongside the usual actor fields, as the receipt: no separate approval step, no new table, just the same audit row every other status change writes, with two more facts on it. A FAILED sub-issue still counts as a blocker — a failure is not the same thing as the work being finished. Response: 200 OK — updated issue object. WebSocket event: issue.updated on workspace:{workspaceId}

Delete Issue

Hard-deletes the issue. Only issues in BACKLOG or CANCELLED status can be deleted.
Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Auth: OWNER, ADMIN, or MANAGER role Response: 204 No Content WebSocket event: issue.deleted on workspace:{workspaceId}

Lifecycle Actions

Drive an issue through agent execution: start, stop, and review.

Transfer Issue Work

Changes the current worker while preserving the accountable owner. Human handoffs pause automatic assignments and create a targeted in-product Inbox message. Agent handoffs prepare a new step for an explicit Start work. Auth: authenticated workspace OWNER, ADMIN, or MANAGER (create). Request body:
Response: 200 OK — {"revision":4,"status":"TODO"}. A replay of the same request returns {"revision":4,"replayed":true} without repeating its effects. Human submit returns status REVIEW. Read the issue again for work_mode, worker_user_id, worker_name, work_note, work_revision, and work_stopping. While stopping is true, active runs have received cancellation but have not yet finished; handing back or submitting must wait. The work state, cancellation, comment and audit receipt commit together. WebSocket events: issue.updated; human handoff also emits inbox.updated. CLI: crewship issue work.

Set Issue Review Policy

Sets whether a person has to accept the work before the issue counts as done. The policy is part of the issue’s work contract, so it can change only while the issue has not started: TODO or BACKLOG, with no assignment still running. Auth: authenticated workspace OWNER, ADMIN, or MANAGER (create). Request body:
Response: 200 OK — {"revision":4,"client_review_required":true}. The revision is the issue’s new work_revision; pass it to the next call that pins one. The CAS on revision is what stops a stale browser from silently removing acceptance that was added to newer work. WebSocket event: issue.updated. CLI: crewship issue review-policy.

Start Issue

Starts execution of a BACKLOG or TODO issue. The issue must have an agent delegate — delegate_agent_id (or an assignee resolved from the legacy assignee_type='agent' pair), naming a live, non-held agent. A human owner with no delegate is not enough (F62: existence of any assignee used to be sufficient; it is not anymore — the delegate must specifically be an executable agent). This: Start takes one of two paths, chosen by whether the issue has a bound routine (routine_id): Mission path (no routine bound). This:
  1. Creates a synthetic chat session for the mission
  2. Creates or resets unfinished tasks to PENDING; completed and skipped history stays intact
  3. Transitions status to IN_PROGRESS
  4. Starts the MissionEngine asynchronously
Routine path (routine_id set, #2448). The bound routine runs instead of the mission engine, under the caller’s own identity and the routine’s public execution gates (the same checks as running the routine directly). The server records an issue_executions row in stage working for the Lead to review, moves the issue to IN_PROGRESS, and answers 202 with the run ID. Stored routine_inputs_json are the run’s inputs; a required input with no value and no default is refused before anything is written. A routine start the routine gate rejects is proxied back with that gate’s status and body, and the execution is marked failed. Missing delegate is not a condition on this path. Auth: OWNER, ADMIN, or MANAGER role Response (mission path): 200 OK
Response (routine path): 202 Accepted
run_id is the routine run to follow (crewship routine records); it is also the execution.routine_run_id on Get Issue. WebSocket event: issue.started on workspace:{workspaceId}

Stop Issue

Stop ships in two tiers (§10.3), both reachable from this one endpoint.Tier 1 — cooperative, always applied. When the issue is IN_PROGRESS or REVIEW, this happens in one transaction: every PENDING/IN_PROGRESS/BLOCKED mission task is marked CANCELLED, every assignment for the issue that has not reached a terminal state — running, pending, or still queued behind a busy agent or the crew budget — gets cancel_requested_at stamped, and the issue itself moves to CANCELLED. An assignment that hasn’t started its exec yet sees the stamp first and never starts. Without hard=true, one already mid-exec finishes that exec, and only then is recorded — as CANCELLED, never COMPLETED or FAILED, even if it reports success or a late failure after Stop was called; the stamp always wins. No further step is scheduled for the issue.Tier 2 — hard, opt in with ?hard=true. After the Tier 1 stamp above has committed, every assignment it reached that is RUNNING gets its own tmux session ended — tmux kill-session -t agent-{slug}, run as a brand-new exec into the same container the run lives in — escalating to a direct kill -KILL on that session’s own pane process group if the run hasn’t exited within a short grace period. Most agent runs own a tmux session named for their own agent slug (the same session a web terminal attaches to), a container-visible identity: ending it ends exactly that agent’s process tree. This never runs a container-level kill and never signals by OS pid: the crew’s container is shared by every agent on the crew, and a pid resolved from outside the container (ExecInspect’s host-namespace pid) is not safe to signal from inside it — #2365 found exactly that bug in the pre-fix mechanism, live on dev1. A sibling agent’s own session is a different name and is never touched, by construction. A run whose exec bypassed tmux entirely (an oversized prompt delivered via stdin, or a container without tmux installed) has no session to end; Tier 2 confirms that first (listing the session’s pane pids before touching it) and reports NOT_FOUND immediately rather than sending a signal that would reach nothing. Bounded to a few seconds. What was signalled and the result (TERMINATED_TERM, TERMINATED_KILL, ALREADY_EXITED, NOT_FOUND, UNSUPPORTED, or ERROR) is written to assignments.hard_stop_result (and hard_stop_at) and to an assignment.hard_stopped journal entry — independent of, and in addition to, the Tier 1 CANCELLED terminal write, which always lands regardless of what the signal achieved. UNSUPPORTED means there is no container-visible identity to signal by at all (no container configured, or the assignee is not an agent); the run is still CANCELLED by Tier 1 either way.An @mention can dispatch a run on an issue that was never started (BACKLOG/TODO) — that run is attributed to the issue the same way a mission-task run is. Stop reaches it too: when the issue is not IN_PROGRESS/REVIEW but has at least one live attributed assignment, Stop stamps cancel_requested_at on it (and, with hard=true, signals it the same way if it is already RUNNING) and leaves the issue’s own status unchanged (it does not move the issue to IN_PROGRESS or CANCELLED). Stop only refuses with 400 when the issue is neither IN_PROGRESS/REVIEW nor has any live attributed run.
Request: crewId and identifier are required path parameters; workspace_id selects the workspace; hard (optional, default false) opts into Tier 2. No request body. Auth: OWNER, ADMIN, or MANAGER role Response: 200 OK
status is CANCELLED when the issue was IN_PROGRESS/REVIEW; otherwise it echoes the issue’s unchanged status (e.g. BACKLOG) for the mention-dispatched-run case above. runs_stopped is the number of assignment rows stamped by Tier 1. hard echoes whether ?hard=true was requested — it does not report per-target Tier 2 results, which live on each assignment row and in the journal instead.

Review Issue

Submit a review decision for an issue in REVIEW or IN_PROGRESS status. Auth: OWNER, ADMIN, or MANAGER role Request Body:
  • approve: Transitions issue to DONE, adds approval comment and activity
  • request_changes: Transitions issue to TODO, optionally reassigns, adds comment and activity, and moves a Lead-accepted (or needs_human) execution to changes_requested with the comment as its note
The review is a compare-and-set against the work contract. Nothing changes on a 409: Response: 200 OK
CLI: crewship issue review sends both revisions from the issue it just read; --revision/--brief-revision pin them.

Bulk

Apply one set of field updates across many issues from the board/list multi-select toolbar.

Bulk Update

Apply a single set of field updates to up to 100 issues at once. Used by the board/list views’ multi-select toolbar. Each issue is processed in its own statement — partial success is possible: a request touching 50 ids where 3 have invalid status transitions returns {"updated": 47} rather than rolling back. Auth: OWNER, ADMIN, or MANAGER role (create permission). Request Body:
Response: 200 OK
When updated > 0, an issues.bulk_updated WebSocket event is broadcast on workspace:{workspaceId} with the count. Per-issue status changes additionally produce activity entries (status_changed, details suffixed with (bulk)).

Sub-issues

Issues can nest via parent_issue_id. List the children of one parent.

List Sub-issues

Returns the children of one parent issue — rows where parent_issue_id equals the resolved parent id. Ordered by sort_order ASC, created_at ASC so the response matches the order operators see in the UI’s nested list. Path parameters: Response: 200 OK — array of full issue objects matching the GET /api/v1/issues shape. Empty array (never null) when the parent has no sub-issues.

Activity

A read-only audit trail of significant issue changes.

List Activity

Returns the activity log for an issue (status changes, assignments, reviews). Limited to 50 most recent entries. Response: 200 OK
Activity Actions: created, status_changed, assignee_changed, priority_changed, description_changed, parent_changed, relation_added, commented, mentioned (an @mention in a comment was recorded for dispatch — the row is the only user-visible trace of the delivery besides the inbox item), attachment_added, attachment_removed, code_link_added, code_link_removed, review_approved, review_changes_requested, task_completed, task_failed, task_cancelled (Stop reached a task or a live run), inbox_acted (a person acted on the issue’s NEEDS_HUMAN inbox card — the receipt names the action, the card, the session’s agent_version and, for an answer, the delivery and run it produced; see Acting on a card) description_changed records that the description was rewritten, never the text itself — details carries only lengths (description updated (128 → 342 chars)). An issue description is unbounded user input, and an audit row is not a revision store; if you need the previous text, read the issue history in your own tooling before you PATCH. Every action above also writes a Crew Journal entry, which is what notifications are routed from — including the ones an agent produces through the internal API. A client that reads the activity list sees the same events a Slack/ntfy subscriber does.

List Events

Work package B11 of the Issues & Routines architecture (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md §14.1/§17, #2368). This is the read side of the B1 event log (mission_activity, work package B1) — the SAME rows List Activity above returns, ordered by seq (not created_at) and not capped at 50.
Exists for one reason: the realtime allowlist (hooks/use-realtime.tsx) only guards which event TYPES a client accepts — it says nothing about whether every frame of an accepted type actually arrived. The server’s WebSocket hub sends non-blocking and silently drops a frame when a client’s send buffer is full. issue.delivery.acked (work package B2) is the one realtime type today whose payload carries a seq; a client that notices its next seq isn’t exactly one more than the last calls this endpoint with after_seq set to the last seq it has confirmed, and gets back everything in between — instead of staying stale until someone reloads the tab.
One call is not guaranteed to return the whole gap: a response is capped at 500 rows regardless of how large the range between after_seq and the mission’s current seq is. A caller that needs everything must keep requesting with after_seq set to the last row’s own seq (not the response’s latest_seq) until the returned events is empty or a returned row’s seq reaches latest_seq. crewship issue events and the board’s client-side gap resync (hooks/use-issue-event-gap-resync.ts) both page this way; treating the first page as the whole answer silently drops everything past row 500.
The path’s {identifier} also accepts the issue’s id, as does GET /api/v1/issues/{identifier}, for issues known only by id. Response: 200 OK
payload_json carries the same action/from/to shape the journal entry gets, plus forced/open_children on a status change that bypassed the terminal-children rule with ?force=true — the receipt naming which children were still open. It is null on a row from before this column existed. Auth: any authenticated workspace member (read).

Get Full Run Result

Returns the full stored result on demand. runId is the assignment id from List Runs, not its trace ID. The assignment must belong to the supplied issue. The list exposes result_truncated for a shortened preview and result_stale when its recorded brief revision predates the current issue brief. Auth: any authenticated workspace member (read). No request body. Query parameter: workspace_id selects the authenticated workspace. Response: 200 OK
result_summary is the stored agent-authored text and may be empty. The endpoint does not invent a deliverable or confirm acceptance of the issue. CLI: crewship issue result.

Runs

The agent executions behind an issue. Issues run on the mission engine (not pipelines), so a “run” is a mission task backed by an assignments row.

List Runs

Lists every run attributed to an issue — not just mission tasks joined to their assignments row, but also mention-dispatched runs (an agent tagged in a comment) and delegation-hop runs (a sub-agent assigned further work via /assign mid-mission). Newest-first. Pages like every list: limit (default 100, max 500) and offset, with the total in the X-Total-Count, X-Limit and X-Offset response headers. Each row is one assignment (an agent task-run) and carries: Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Auth: any authenticated workspace member (read). Response: 200 OK — a JSON array (never null).
hard_stop_result/hard_stop_at are omitted here — this run was never hard-stopped. A run Tier 2 reached instead carries them, e.g. "hard_stop_result": "TERMINATED_TERM", "hard_stop_at": "2026-09-04T10:00:05Z".

Outcome

Work package B6 of the Issues & Routines architecture (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md §9.6). status (above), outcome and runverdict answer three different questions and are easy to conflate — see docs/guides/routines.mdx’s “Status, outcome and verdict” section for the full comparison.
Every finished run — an issue-session assignment or a routine (pipeline_runs) run — carries an outcome, the deterministic routing decision the runner reports, distinct from its technical status:
What this table describes vs. what is wired today. NEEDS_HUMAN’s inbox routing and the session-state transition below are real, outcome-driven effects — this PR (B6) added them. The ”+ an issue comment” cells for WORK_CREATED/PARTIAL are §9.6’s stated intent, not yet a real, outcome-gated effect: an issue-session assignment already gets a mission comment posted on completion (finishAssignment), but that path predates outcome and fires for ANY non-empty, non-error result — a SUCCEEDED or NO_CHANGE run with output gets one too, and a routine (pipeline_runs) run gets no issue comment at all regardless of outcome, since no comment-posting path exists for that run shape yet. Gating the comment on outcome specifically (and adding one for routine runs) is a follow-up, not part of this PR.
outcome is parsed from the agent’s existing structured hand-off — the ---CHECKPOINT--- block a session-bearing run is instructed to emit (§11.3), or the ---HANDOFF--- block a mission-task run emits — never a second, invented block. A run that ends cleanly (no execution error) but reports no recognised outcome is recorded FAILED, with error_message set to "no outcome reported" — an absent outcome is treated as a bug, not a silent success. That default applies wherever somebody could have reported one: an agent run always could, and so could a routine holding an agent_run (or a call_pipeline, whose target may hold one). A routine with no agent step at all — an agentless: true probe, or one built only from script / transform / notify / crewship steps — could never report one, so a clean run there is recorded SUCCEEDED with an empty error_message rather than being flagged for a hand-off that was impossible by construction. A run stopped by Stop (Tier 1 cooperative cancellation) or superseded always gets CANCELLED, regardless of what its own hand-off said. NEEDS_HUMAN is the only outcome that creates an inbox item — exactly one, keyed on the run id, so a retried terminal write can never double it. The item’s payload_json carries a §12 action contract:
For a session-bearing assignment, NEEDS_HUMAN also moves the issue’s session to awaiting_input (see Sessions, below) — the transition that needed this column to exist before it could be reached at all. WebSocket event: run.outcome on workspace:{workspaceId} — {mission_id, identifier, assignment_id, status, outcome} (work package B11, #2368). Fired once the outcome above is decided, for a run attributed to an issue; not fired for a run with no mission_id (a root /assign).

Sessions

Session lifecycle: issue_agent_sessions is work package B1 (event log + session foundations) of the Issues & Routines architecture (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md), extended by B3 (#2339, the run-admission guarantee: at most one assignments row may be PENDING/QUEUED/RUNNING for a given session at a time, a partial unique index rather than a read-then-write check), B4 (#2343) and B6 (#2349, the outcome contract): state genuinely moves with its runs — pending → active when a run wins the admission CAS, then, once that run settles, resolved through its outcome — active → idle for NO_CHANGE/SUCCEEDED/WORK_CREATED/PARTIAL/CANCELLED, active → error for FAILED, and active → awaiting_input for NEEDS_HUMAN (the transition B4 could not reach until outcome existed) — with active_run_id populated for as long as a run is live, cleared once it settles. What is still NOT wired: the 14-day idle → stale sweep. An ephemeral agent that expires mid-session is reconciled onto error (was active) or closed (otherwise) rather than left rendered active over a ghost.
The durable, per-(issue, agent) cursor a mention resolves or creates. UNIQUE(mission_id, agent_id) on the underlying table means mentioning the same agent on the same issue twice never creates a second session — the second mention finds and reuses the first’s row. UNIQUE on assignments.session_id (partial, live statuses only) means a session backs at most one live run: a mention that lands while the session’s run is already going does not start a second one — its delivery is left unclaimed and is folded into exactly one new run once the live one finishes, rather than being marked seen by a run that never read it. See docs/guides/issue-mentions.mdx’s “Known limits in 1.0” for the full account. WebSocket event: issue.session.state on workspace:{workspaceId} — {mission_id, identifier, session_id, agent_id, state} (work package B11, #2368). Fired on every landed transition above; not fired for a transition the row’s own guard refused (e.g. activating a closed session).

List Sessions

Lists every issue_agent_sessions row for this issue, newest-updated first. Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Auth: any authenticated workspace member (read). Response: 200 OK — a JSON array (never null).

List Checkpoints

Work package B5 of the Issues & Routines architecture (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md §9.5, #2345). A checkpoint is the structured state a session-bearing run reports at the end of every run (done/plan/facts/blockers/next_step/confidence), so the next run on the same session can resume without rediscovering it. It is what the §11.1 context pack’s “latest checkpoint” section is built from — see Sessions, checkpoints and the context pack.
Lists every agent_session_checkpoints row for one session, newest-first — the session’s full resumable history, not just its current cursor (GET .../sessions already reports last_consumed_seq). WebSocket event: issue.checkpoint.written on workspace:{workspaceId} — {mission_id, identifier, session_id, agent_id} (work package B11, #2368). Fired when a new row lands here, so an open session panel can refresh without polling. Request: crewId, identifier and sessionId are required path parameters; workspace_id selects the workspace. sessionId must belong to the named issue, or the request 404s. No request body. Auth: any authenticated workspace member (read). Response: 200 OK — a JSON array (never null).

Comments

Threaded discussion on an issue, ordered oldest-first.

List Comments

Returns comments on an issue in chronological order. Legacy reads without page_size return all comments. Set page_size=100 for the latest page (maximum 100) and pass its oldest comment ID as before_id to read older messages. X-Has-More: true indicates another page. The stable timestamp + ID cursor is not shifted by a newly posted comment. Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Response: 200 OK

Create Comment

Auth: OWNER, ADMIN, or MANAGER role Request Body:
Response: 201 Created — comment object with author_type: "user".

Relations

Link issues to one another (blocks, relates to, duplicate of). Relation types are flipped when viewed from the target side.

List Relations

Returns all relations for an issue. Relation types are flipped when viewed from the target side (e.g., blocks becomes blocked_by). Request: crewId and identifier are required path parameters; workspace_id selects the workspace. No request body. Response: 200 OK

Create Relation

Auth: OWNER, ADMIN, or MANAGER role Request Body:
Internally, blocked_by is stored as blocks with source/target swapped for normalization. Response: 201 Created

Delete Relation

Auth: OWNER, ADMIN, or MANAGER role Request: relationId is a required path parameter and workspace_id selects the workspace. No request body. Response: 200 OK with {"status": "ok"}
Attach the pull request / merge request that resolves an issue. The provider is recognised from the URL’s path grammar (/pull/<n> → GitHub, /-/merge_requests/<n> → GitLab), so github.com, gitlab.com and self-hosted instances of either all work. State is fetched through the provider’s REST API using a stored GITHUB / GITLAB credential — see Git links for how the credential is chosen and how private-network forges are allowed.
Response: 200 OK
state is OPEN, DRAFT, MERGED or CLOSED — normalised across both providers. A merged GitHub pull request reports closed with a merged_at; Crewship records it as MERGED.
Auth: OWNER, ADMIN, or MANAGER role Request Body:
Response: 201 Created — the stored link, the same object shape List code links returns.
The state is fetched before the row is written, so a 201 means the link was reachable. Errors are application/problem+json (RFC 7807) with a stable type URI under https://crewship.ai/problems/code-link/ and a code member, because several failures share a status:
Auth: OWNER, ADMIN, or MANAGER role Request Body: none. The link is addressed by the linkId path parameter; workspace_id is the only query parameter. Re-reads the link from its provider and returns the updated object. State is never polled in the background; this is the only refresh path. Response: 200 OK — the refreshed link, the same object shape List code links returns, with last_synced_at moved forward and last_sync_error cleared. A failed refresh keeps the previously fetched state and records the reason on the row (last_sync_error), then returns the same problem document the attach path would.
Auth: OWNER, ADMIN, or MANAGER role Request Body: none. The link is addressed by the linkId path parameter; workspace_id is the only query parameter. Response: 200 OK with {"status": "ok"} Hard delete — the row goes, the way a mission_relation does. Nothing about the pull request on the forge changes.

Attachments

Attach files to an issue — a crash log, a screenshot, a repro bundle, a diff. Everything an agent working the issue needs to see and, before this surface existed, had to be pasted into a comment. Three properties are worth knowing before you use it:
  • The type is decided by the file’s extension, against an allowlist. The request’s own Content-Type is discarded. That header is chosen by whoever is uploading, and honouring it is how a stored file becomes stored XSS served from your own origin. Allowed today: .txt .log .md .csv .tsv .json .yaml .yml .toml .xml .diff .patch .png .jpg .jpeg .gif .webp .avif .pdf .zip .gz .tgz. .html, .svg and anything executable are absent deliberately, not by oversight.
  • Files are stored by content. Two issues carrying identical bytes share one stored copy, and attaching the same file to the same issue twice is the same attachment — the second call returns 200 with the existing record rather than 201 or 409, so a retry is safe. “The same file” means the same bytes under the same name: crash-before-fix.log and crash-after-fix.log can be byte-identical and mean opposite things, so both are attached and both get 201, sharing one stored copy.
  • The uploaded filename is a label, never a path. The stored location is derived entirely from the SHA-256 digest, so a filename of ../../../etc/passwd is recorded as passwd and the bytes land where every other attachment’s do.
Agents reach the same files through the sidecar — see Attachments an agent can read.

List attachments

Auth: any workspace member (session or CLI token). The workspace is taken from the authenticated session, so an issue in another workspace is a 404. Request: workspace_id is the required workspace query parameter. No request body. Response: 200 OK — newest first.
sha256 is part of the contract: it lets a client verify what it downloaded, and lets an agent decide it has already read this exact file. The on-disk storage key is deliberately not exposed.

Upload an attachment

Auth: OWNER, ADMIN, or MANAGER role Request Body: multipart/form-data with one part.
Response: 201 Created — the stored record, the same object shape List attachments returns. 200 OK instead of 201 means this exact file — these bytes, under this filename — was already attached to this issue: no second record was created, no second timeline entry was written, and the returned object is the one that already existed. The same bytes under a different filename are a different attachment and get 201.

Download an attachment

Auth: any workspace member. Request: workspace_id is the required workspace query parameter. The file is addressed by the attachmentId path parameter. No request body. Response: 200 OK — the raw bytes, with three headers that matter: An attachment belonging to a different issue — even one in the same workspace — is a 404 through this issue’s URL, not a 403.

Delete an attachment

Auth: OWNER, ADMIN, or MANAGER role Request Body: none. The file is addressed by the attachmentId path parameter; workspace_id is the only query parameter. Response: 200 OK with {"status": "ok"} Hard delete of the record, and of the stored bytes — but the bytes only when no other issue, comment or chat in this workspace still references them. Removing a file from one issue never removes it from another that carries the same file.

Labels

Labels are workspace-scoped and can be attached to any issue.

List Labels

Request: workspace_id is the required workspace query parameter. No request body. Response: 200 OK

Create Label

Auth: OWNER, ADMIN, or MANAGER role Request: workspace_id selects the workspace. The JSON body requires name and color; label_group is optional. Response: 201 Created

Update Label

Auth: OWNER, ADMIN, or MANAGER role Request: labelId is required, workspace_id selects the workspace, and the JSON body may contain name, color, and/or label_group. All fields optional: name, color, label_group. Response: 200 OK — updated label.

Delete Label

Auth: OWNER or ADMIN role Request: labelId is a required path parameter and workspace_id selects the workspace. No request body. Response: 204 No Content

Projects

Projects group related issues together with tracking metadata.

List Projects

Query Parameters: Response: 200 OK

Create Project

Auth: OWNER, ADMIN, or MANAGER role Response: 201 Created

Get Project

Response: 200 OK — project with computed issue_count, done_count, and progress fields.

Update Project

Auth: OWNER, ADMIN, or MANAGER role All fields optional: name, description, icon, color, status, priority, health, lead_type, lead_id, start_date, target_date. Response: 200 OK — updated project.

Delete Project

Auth: OWNER or ADMIN role
Unlinks all issues from the project before deletion.
Response: 204 No Content

Project Stats

Returns breakdown data: counts by status, by assignee, by label, and crew list. Response: 200 OK

Milestones

Milestones belong to projects and expose progress counts for the issues linked to them. All routes are workspace-scoped through the project or milestone.

List Milestones

Request: projectId is a required path parameter and workspace_id selects the workspace. No request body. Returns 200 OK with an array ordered by position, then creation time. Each item contains id, project_id, name, nullable description and target_date, status, position, issue_count, done_count, created_at, and updated_at. A project outside the workspace returns 404.

Create Milestone

Auth: OWNER, ADMIN, or MANAGER role. The request requires name; optional fields are description, target_date, and status (default active). The response is 201 Created with the full milestone object and its next position.

Update Milestone

Auth: OWNER, ADMIN, or MANAGER role. Any of name, description, target_date, status, and position may be updated; at least one field is required. Returns 200 OK with the updated milestone, or 400 for invalid JSON/no fields and 404 when it is not in the workspace.

Delete Milestone

Auth: OWNER or ADMIN role. Returns 204 No Content, or 404 when the milestone is not in the workspace.

Reference

Issue Statuses

Priority Levels