# sneck > A local-first issue tracker for teams that close work calmly. One Elixir/Phoenix app (ElectricSQL sync embedded, zero node) exposing a REST API v1 and an MCP server for agents. New issues land in `backlog` - everywhere. Base URL for everything below: `http://localhost:4000` (dev server). ## Docs - [README](https://gitlab.com/sneck-app/sneck/blob/main/README.md): run instructions, architecture table, dependency gotchas - [The Sneck Method](https://gitlab.com/sneck-app/sneck/blob/main/docs/method.md): the workflow canon - backlog intake, fixed cycles, projects with specs, soundings - [OpenAPI 3.1 spec](https://gitlab.com/sneck-app/sneck/blob/main/docs/openapi.yaml): the full /api/v1 contract - schemas, error envelope, cursor pagination, idempotency - [AGENTS.md](https://gitlab.com/sneck-app/sneck/blob/main/AGENTS.md): repo map, run/test commands, landmines for coding agents ## API JSON over `/api/v1`. Bodies are flat snake_case (`{"title": ...}`) - never nested. Ids are UUIDs; every issue also carries `number` (unique within its own prefix scope, not the whole workspace). A workspace's issues display under MULTIPLE prefixes, Jira-project style: the org prefix (derived from the org name) plus one OPTIONAL prefix per team/crew - a "Signal" crew with prefix `SIG` shows its issues as `SIG-7` everywhere (cards, rows, docs, URLs). **IDs are unique to the prefix**: each prefix owns its own number sequence - `SIG-7` and `SNECK-7` are different issues; a ref is a (prefix, number) pair. Prefixes can be updated (re-skins the refs, never renumbers; links carrying a renamed-away prefix 404 honestly rather than land on a same-numbered issue). Always address issues by their full prefixed ref (`SIG-7`), including deep-link params (`?issue=SIG-7`) - **bare numbers are retired** and rejected with `bare_number_retired`. - Auth: required on every `/api/v1` route (anonymous → 401 `unauthenticated`). Two identities: a browser session cookie, or `Authorization: Bearer sneck_…` - a personal access token (scope `api` for REST, `mcp` for the MCP server; mint one in Settings → API tokens, the plaintext is returned exactly once). The org is pinned server-side by the identity, so agents can't cross tenants. On writes, send `Idempotency-Key` (a fresh UUID per logical request, ≤255 bytes): the first response is cached 24h, retries replay it verbatim (`Idempotency-Replayed: true`), and reusing a key with a different request is 409 `idempotency_key_reused`. - Errors are always one envelope: `{"error": {"code": "...", "message": "...", "doc_url": null, "details": {...}}}`. `code` is a stable snake_case string; `message` states the fix (allowed values, length bounds). - Lists: `GET /api/v1/issues?status=&priority=&fq=&archived=&limit=` returns `{"data": [...], "next_cursor": "..." | null}`. `limit` is 1..100 (default 25); order is newest first; pass `next_cursor` back as `cursor` for the next page. Archived issues are excluded unless `archived=true` (rows carry `archived_at`). - fq - the filter-query grammar (`GET /api/v1/issues?fq=…` and MCP `search_issues`'s `query` arg): whitespace-separated `key:value` tokens plus free text. AND across keys, OR within (`status:todo,in_progress`); comparators glue to numeric keys (`priority:>=2`, `estimate:<=13`); negation is `-key:value` (status/type/priority only); quotes make one value (`label:"needs work"`); bare words become one title-contains clause. Keys: `status type priority label assignee project component cycle team version estimate has is blocked due` + any ACTIVE custom-field key (`risk:high`). `type:` is the issue type (`story|bug|task|epic`, default `task`). `assignee:me` is you, `assignee:none` unassigned, `cycle:current` the active cycles, `team:` takes name/key/uuid, `version:` the fix version's name or uuid. `component:` takes a project component's name or uuid (a name matching several projects' components matches them all; the browser/list surfaces and the API v1 issue list + MCP `search_issues` all apply it). Names resolve inside your workspace only. fq overrides the structured `status`/`priority` params per key. `blocked:` is boolean, `due:` takes `overdue`/`week`/an ISO date - both apply on the API and MCP surfaces (the same clauses as the browser /list). - Statuses: `backlog | todo | in_progress | done | canceled`. Priority: 0 = none .. 3 = urgent. Estimate: `null` (default) or one of `0,1,2,3,5,8,13,21,40` (a PATCH `estimate: null` clears it back to unestimated). - Issue templates (org-level prefills): `GET/POST /api/v1/templates`, `PATCH/DELETE /api/v1/templates/:id`. Body: `{"name","body","description?","estimate?","icon?","labels?":[names],"is_default?"}`. One default per org. `icon` is the closed glyph set `◆ ▲ ● ■ ✦` or null. Picker order: `POST /api/v1/templates/reorder {"ordered_ids":[uuid,…]}` (unknown/foreign ids ignored). - Custom fields: `GET/POST /api/v1/fields` (body `{"key","label","field_type":"text|number|select|multi_select|date|url","select_options?","position?","active?"}`), `PATCH/DELETE /api/v1/fields/:id` (type is immutable). Values ride `GET /api/v1/issues/:id/fields` and `PATCH /api/v1/issues/:id/fields {"key":"risk_level","value":"high"}` - value must fit the type; `null`/`""`/`[]` clears. - Relations: `GET/POST /api/v1/issues/:id/relations` (`{"related_issue_id","relation_type":"blocks|blocked_by|relates_to|duplicate_of"}` - types read from the path issue's perspective), `DELETE /api/v1/issues/:id/relations/:relation_id`. - Issue clone: `POST /api/v1/issues/:id/clone` → 201 - deep copy (description, type, priority, estimate, due date, labels, components, custom values, team/project links; comments, relations and watchers stay behind). A fresh number mints in the source's own prefix scope; a transition-rule-blocked status falls back to `backlog`. Title gains ` (copy)` when unchanged. Fires `issue.cloned`. - Duplicate merge: `POST /api/v1/issues/:id/merge` (`{"into":""}`) - lead/admin only. The canonical absorbs the duplicate's comments (timestamps preserved), relations, watchers, labels/components (union) and custom values (canonical wins); the duplicate is canceled and its ref/URL redirects to the canonical (one hop). Fires `issue.merged` on both. - Comments: `GET/POST /api/v1/issues/:issue_id/comments` (body `{"body": "markdown, @handles mention members"}`; newest first, cursor-paginated) and `PATCH/DELETE /api/v1/comments/:id` (author or workspace admin). Mentions fire server-side on create; the create response echoes the resolved `mentioned` list. - Doc comments: `GET/POST /api/v1/docs/:doc_id/comments` - the wiki's thread under the same list/create contract (markdown bodies, newest first, cursor-paginated, mentions fire server-side with the resolved `mentioned` echo). Fires `doc_comment.created`; row-level edit/delete ride the docs endpoints' next pass. - AI triage suggestions: `POST /api/v1/issues/:id/suggest` - one suggestion per call from the org's own configured OpenAI-compatible endpoint (Settings → AI). Returns `{"suggestion":{"labels":["bug"],"priority":2,"team":"Signal"}}` or typed errors (409 `ai_not_configured`, 403 viewer, 502 upstream). Never auto-applied: the caller writes through the normal label/priority endpoints. Prompts carry the issue's title + description to the configured endpoint. - Sub-issues (one level, no grandchildren): `GET/POST /api/v1/issues/:issue_id/sub-issues` (POST body = the issue create body; a child under a child is 422 `nested_too_deep`), `DELETE /api/v1/issues/:issue_id/sub-issues/:id` (unlinks - the child survives as a standalone issue). GET includes the completion `rollup` (`total`/`done`). - Issue delete: `DELETE /api/v1/issues/:id` → 204 - hard delete (comments, activities, label links, attachments, time entries, relations and custom values cascade; sub-issue children become top-level). Archive - not delete - is the default exit for done work; this is for genuinely wrong rows. Fires `issue.deleted`. - Labels: `GET/POST /api/v1/labels` (body `{"name","color?":"#rrggbb"}`), `PATCH/DELETE /api/v1/labels/:id`. Writes need a member seat; deleting a label cascades its issue links (issues survive). - Projects - the planning layer above issues: `GET/POST /api/v1/projects` (body `{"name","spec?":"markdown","status?":"planned|in_progress|done","initiative_id?","target_cycle_id?"}`), `GET/PATCH/DELETE /api/v1/projects/:id` (PATCH: explicit `null` clears spec/initiative/cycle). List filters: `status`, `initiative_id`, `limit`, `cursor` (no fq). Every row carries `health` - the LATEST project update's report (`on_track|at_risk|off_track|not_set`, or `none` = never posted; post reports via `POST /api/v1/projects/:id/updates`) - and `rollup` (`count`/`points`/`done_points` over the project's issues' estimates). DELETE is hard: issues survive (their project link clears), the project's updates/guests cascade. - Initiatives - the destinations projects group under: `GET/POST /api/v1/initiatives` (body `{"name","description?","status?":"active|completed"}`), `GET/PATCH/DELETE /api/v1/initiatives/:id`. Every row carries `health` - the WORST-OF over its projects' healths (any `off_track` wins, then any `at_risk`; never-posted projects are neutral) - and a lean `projects` list (`id`/`name`/`status`/`health`). DELETE is hard: projects survive unparented. - Time entries: `GET /api/v1/issues/:issue_id/time?limit=&cursor=` (entries newest first + `total_minutes`), `POST /api/v1/issues/:issue_id/time` (`{"minutes":1..1440,"description?","date?":"YYYY-MM-DD"}` - the same ledger the timer and /time write, credited to the caller), `PATCH/DELETE /api/v1/time-entries/:id` (author or workspace admin; `date` moves the day). A team-scoped PAT logs and edits only its own team's cargo (403 `team_scope`). - Time summary (read-only, org-level): `GET /api/v1/time/summary?period=this_week|last_week|this_month|custom&from=&to=` - per-person logged minutes for the window AND the same-shape trailing window before it (both named in the response: `previous_from`/`previous_to`), with org totals and signed deltas. Doer seats only (viewers → 403 `time_summary_member_required`; the same numbers render on `/time` and `/workload`). No span cap. - Teams: `GET /api/v1/teams` (unpaginated, roster `member_ids` included), `GET /api/v1/teams/:id`, `POST /api/v1/teams` (`{"name","key?","issue_prefix?"}` - admin-only), `PATCH /api/v1/teams/:id` (`{"name?","issue_prefix?"}` - `null` prefix falls back to the workspace prefix; `key` is create-only), `POST /api/v1/teams/:id/members {"user_id"}` / `DELETE /api/v1/teams/:id/members/:user_id` (idempotent; the user must hold an org seat). Writes are lead-grained: org admin or the team's team-admin bit (403 `team_forbidden`); no DELETE of the team itself - teams are containers. - Cycles: `GET /api/v1/cycles?team_id=&status=&limit=&cursor=` (newest box first, team preloaded), `GET /api/v1/cycles/:id`, `POST /api/v1/cycles/:id/close` (admin-gated; runs the ONE close path - the response carries the closed cycle, `moved_issue_ids`, `kept_issue_count` per the org's carry-over setting; closing a completed box is 422 `cycle_not_active`). **No create** - cycles are the Method's cadence: a box opens by rolling a team forward, never via the API. - Versions: `GET/POST /api/v1/versions`, `GET/PATCH/DELETE /api/v1/versions/:id`, `POST /api/v1/versions/:id/release` (stamps `release_date` - kept on re-release), `POST /api/v1/versions/reorder {"ordered_ids":[…]}` (the FULL permutation). Rows carry `position` (picker order) and `progress` (`total`/`done`); DELETE nilifies issues' fix-version links (issues survive). The `version:` fq key's backing rows. - Saved views (filter presets): `GET/POST /api/v1/views`, `GET/PATCH/DELETE /api/v1/views/:id`. Body: `{"name","filter":{...},"shared?","position?"}`. - Users - the org roster: `GET /api/v1/users` (unpaginated; rows are MEMBERSHIP-keyed - `id` is the roster row's, embedding `user_id`/`name`/`email`/`role`/`weekly_capacity`/`team_ids`), `PATCH /api/v1/users/:id {"role":"owner|admin|member|viewer"}` - **admin-gated** (403 `role_change_forbidden`); demoting the last admin-capable seat is 409 `last_admin`. No seat-removal door (deprovisioning is SCIM's/members-page doctrine). - Notifications - your inbox: `GET /api/v1/notifications?unread=true&limit=&cursor=` (the CALLER's rows in the pinned org, newest first, with the org-scoped `unread_count`; `reason` is the badge vocabulary), `POST /api/v1/notifications/:id/read` (idempotent), `POST /api/v1/notifications/read-all` (org-scoped sweep). No `user_id` param - an agent reads its own bell only. - Docs - the wiki: `GET/POST /api/v1/docs`, `GET/PATCH/DELETE /api/v1/docs/:id`. Body `{"title","body?","project_id?","team_id?"}` (project/team are create-only - the surface edits words, it does not move pages; body is raw markdown, may be empty). List filters: `q` (title/body substring), `project_id`, `team_id` + `limit`/`cursor`. Delete is admin-or-creator and loud in the audit log (the full body survives there). - Issue attachments: `POST /api/v1/issues/:issue_id/attachments` (multipart, `file` part, 10 MB cap) → 201 with `data.storage_key`; `DELETE /api/v1/issues/:issue_id/attachments/:id`; files stream from `GET /attachments/:id/:filename` (org-gated). - Org data export (async jobs): `POST /api/v1/export` (body `{"scope":"full"|"csv_issues"}`, default full) → 202 with the job envelope; poll `GET /api/v1/export/:id` (`row_counts` grows as collections stream) until `status` is `done`; download from `GET /api/v1/export/:id/download` (append `?delete=true` to burn row + bytes after the last chunk). - Jira import: `POST /api/v1/import/jira` - a Jira Cloud export JSON in the raw body, a multipart `file`/`export` part, or a `json` form field. Idempotent: issues carrying a `jira:KEY-123` label from a previous import are skipped (a changed status is upserted). Epics → projects, sprints → cycles. - Liveness probe: `GET /healthz` → `200 {"status":"ok"}` - unauthenticated, never touches the database. Deep readiness: `GET /readyz` - db ping + Electric stack + worker liveness as state labels (`200 ready` / `503 unready` with the first failing check named); also unauthenticated and rate-limit exempt. ## Outbound webhooks - subscribe to sneck events curl -s -X POST localhost:4000/api/v1/webhooks \ -H 'content-type: application/json' \ -d '{"name":"CI","url":"https://ci.example.com/hooks/sneck","events":["issue.status_changed","issue.created","issue.updated","comment.created"]}' → 201; the `secret` field appears ONCE - verify deliveries with HMAC-SHA256 over `"."` from `x-sneck-signature: t=…,sha256=…`. Endpoints: `GET /api/v1/webhooks` (no secrets) · `GET/PATCH/DELETE /api/v1/webhooks/:id` · `POST /api/v1/webhooks/:id/rotate-secret` (new secret, returned once) · `GET /api/v1/webhooks/:id/deliveries` (status/attempts/last_error). Delivery: 2xx ok; 4xx dead immediately; 5xx/timeout retried 30s/2m/10m/1h, dead after 5 attempts. https-only; localhost/private targets rejected. ### Event catalog Every delivery POSTs one envelope: `{"event", "org_id", "data": {…}, "occurred_at"}` - the `data` shapes below. A webhook only receives the events it subscribed to. | event | `data` shape | when it fires | |---|---|---| | `issue.created` | id, number, title, description, status, priority, estimate, assignee_id, team_id, cycle_id, project_id, updated_at | an issue is created | | `issue.updated` | same as issue.created | any issue field edit (estimate rides this - no separate estimated event) | | `issue.status_changed` | same as issue.created | a status transition (rides on top of issue.updated) | | `issue.archived` | same as issue.created + archived_at | an issue leaves the working set (opt-in subscription - no inbox/Slack bell rings) | | `issue.unarchived` | same as issue.created + archived_at (null) | an issue returns to the working set | | `issue.deleted` | same as issue.created (the row's final state) | an issue is hard-deleted (`Work.delete_issue` - comments/activities/links cascade, sub-issue children become top-level; archive, not delete, is the default exit) | | `issue.cloned` | same as issue.created | a clone is created from a source issue (the new copy's event) | | `issue.merged` | same as issue.created + merged_into_id | a duplicate is merged away (fires on the duplicate) and on the surviving canonical (same shape, no merged_into_id) | | `comment.created` | id, issue_id, issue_number, body, author_id | a comment is posted | | `comment.updated` | same as comment.created | a comment's body is edited (mentions never re-fire on edits) | | `comment.deleted` | same as comment.created (the row's final state) | a comment is deleted | | `doc_comment.created` | id, doc_id, doc_title, body, author_id | a comment is posted on a doc | | `doc_comment.updated` | same as doc_comment.created | a doc comment's body is edited (mentions never re-fire on edits) | | `doc_comment.deleted` | same as doc_comment.created (the row's final state) | a doc comment is deleted | | `project.created` | id, name, spec, status, initiative_id, target_cycle_id, updated_at | a project is created | | `project.updated` | same as project.created | any project edit | | `project.deleted` | same as project.created (the row's final state) | a project is hard-deleted - its issues survive unprojected (project_id cleared), its updates/guests cascade | | `project_update.created` | id, project_id, project_name, health, body, author_id, updated_at | a status post / health report lands on a project | | `project_update.deleted` | same as project_update.created (project_name null) | a status post is deleted | | `cycle.started` | id, name, team_id, status, starts_at, ends_at | a team's next cycle opens (`start_next_cycle`: a manual start, the carry policy, or the recurring cadence's back-to-back box) | | `cycle.closed` | id, name, team_id, status, starts_at, ends_at, moved_issue_count, kept_issue_count | a cycle is closed (automation `expire_cycle`; idempotent re-runs never re-fire) | | `member.invited` | id, email, role, invited_by, expires_at | an invite is issued or superseded (never the token) | | `member.joined` | membership_id, user_id, email, role | an invite is accepted | | `label.created` | id, name, color, updated_at | a label is created | | `digest.ready` | schedule, period{from,to}, generated_at, new_issues, status_changes, comments, mentions, project_updates, cycle_events | the periodic org summary (Sneck.Digest worker) | ## MCP Stateless Streamable-HTTP MCP at `POST /mcp` (JSON-RPC 2.0, no sessions - `initialize` is advisory; `GET`/`DELETE` return 405). Auth: a session cookie or an `mcp`-scoped `Authorization: Bearer sneck_…` PAT (plus the `SNECK_MCP_TOKEN` server env when deployed). Twenty-three tools, advertised per the token's scopes: a read-scoped `mcp:read` PAT sees only the eight read tools, `mcp:write` (alone or with read) the fifteen write tools, and a token with no MCP scope at all gets the honest empty list plus a `_meta.doc` pointing at Settings → API tokens. A `prompts` capability rides alongside: `prompts/list` + `prompts/get` answer three canned workflows - `triage_backlog` (args: team?), `breakdown_epic` (args: issue), `sprint_report` (args: team, cycle) - as instruction texts naming the tool calls to make; prompts list with ANY token (the calls they instruct still gate): - `search_issues {query, status?}` - title substring and/or an fq filter query (the grammar in the API section above), ≤25 compact rows; resolve ids here first - `get_issue {id}` - full issue plus a note teaching the status lifecycle; `comments_summary.count` is the thread length; `labels` are the label names, `estimate` the point-scale estimate, `type` the issue type, `due_date` the ISO day, `assignee`/`assignee_id` who carries it, `cycle` (`id`/`name`/`status`) and `project` (`id`/`name`/`status`) appear when set (with `initiative` `id`/`name` under a project), and `time_summary.total_minutes` is the logged-work total - `get_transitions {issue}` - the transition table FOR THE CALLING ACTOR: every status the issue may move to, each with the rule that governs it (`allowed_roles`, `required_fields` - null when the team left the destination unconfigured), `allowed` (role ladder AND field guards, for you), `missing_fields` (set them in the SAME update_issue call and the gate passes), the team's own display `label`, `override_available` (an org admin seat), `system_bypass` (the actor-less service-token path). The pre-flight read before any status move - `list_teams {}` - every team in the workspace, ordered by name: `id`, `key`, `name`, `issue_prefix` (null = the workspace prefix), `member_count`, the monthly `budget` caps (`monthly_hours_cap`/`monthly_points_cap`, null when unset), and the active cycle's name when one runs - the discovery read for `update_team`/`create_issue`'s `team` addressing (key, name, or UUID) - `get_team {team}` - one team by key, name, or UUID, same row shape plus the roster: every seated member's `id`, `email`, and `team_admin` bit (the lead grain `update_team` accepts) - `list_projects {}` - every project, ordered by name: `id`, `name`, a truncated `spec_summary` (null when no spec; the full text rides `GET /api/v1/projects/:id`), and `open_issue_count` (not done, not canceled) - `list_cycles {team?}` - the workspace's cycles (the teams' timeboxes), newest first, one crew's when `team` (key/name/UUID): `id`, `name`, `goal`, `status` (`active`/`completed`) plus the `active` boolean, the `starts_at`/`ends_at` window, and the team - `get_cycle {cycle, team?}` - one cycle in full plus its cargo counts (`issues`, `done` incl. canceled, `unfinished`, `points`, `done_points` - the burn's ends); `cycle` is the UUID or name, and a name repeating across teams needs `team` to qualify (else a typed 422 naming the contenders) - `create_issue {title, description?, type?, priority?, estimate?, team?, project?, cycle?, labels?, assignee?, due?}` - lands in `backlog`; ONE call, every field. `type` is story|bug|task|epic (default task). `team` files the issue under a crew (its key, name, or UUID) so it numbers in the TEAM's own ref scope and its ref wears the team's prefix (the workspace prefix when the team has none) - create-only, no team arm on update_issue. `project` is the project's name/UUID, `cycle` a cycle name or `"current"` (the target team's active box), `labels` existing label names (an unknown name is a typed 422 naming it - never invented), `assignee` a member's UUID or the literal `"me"`, `due` an ISO day. The arms resolve BEFORE the single insert: an unknown name anywhere creates nothing - `update_issue {id, title?, description?, status?, type?, priority?, estimate?, assignee?, project?, cycle?, due?, labels?}` - partial update, one call; explicit `null` CLEARS the nullable fields (`description`, `estimate`, `assignee`, `project`, `cycle`, `due` - absent ≠ null), while a `null` `title` is a 422 (title is required, never nullable). `labels` REPLACES the whole set (Jira's set semantics; null or [] clears to empty). Arms apply in a fixed order - labels, due, then the row fields with status last - so one call moving into a guarded status satisfies that status's required_fields; a failure stops there and `details.landed` names the arms that already wrote. The response's `applied` lists the arms that wrote - `add_comment {issue_id, body}` - post a markdown comment; `@handle` mentions notify resolved workspace members (never you); returns the comment + the mentioned set - `set_custom_field {issue_id, key, value?}` - set one custom field value (null clears); list keys via `GET /api/v1/fields` - `add_label {issue_id, label}` - add a label BY NAME (must already exist - an unknown name answers a typed error listing the workspace's labels); idempotent; returns the issue's whole label set - `remove_label {issue_id, label}` - remove a label by name; idempotent; returns the issue's whole label set - `set_cycle {issue_id, cycle?}` - set the issue's cycle by name, `"current"` (its OWN team's active cycle), or null to unassign; names repeat across teams (the issue's own team's cycle wins ties) - `set_project {issue_id, project?}` - set the issue's project by name, or null to unset - `log_time {issue_id, minutes, note?, date?}` - log work through the same ledger the timer and `/time` write (≤1440 minutes per entry; `date` is an ISO YYYY-MM-DD defaulting to today); credited to the calling identity - `start_next_cycle {team}` - roll a team's cadence forward: complete the active box (if any) and open the next 14-day "Cycle N" - the same one-door rollover the cycle menu runs. Lead-grained (org admin or the team's own team admin); no create exists - boxes mint only by rolling forward - `close_cycle {cycle, team?}` - close an ACTIVE cycle through the one close path the cycle page runs: unfinished cargo moves per the workspace's carry-over setting (`carry` opens the next box and moves it, `backlog` drops it there, `none` keeps it), the `cycle.closed` webhook fires. Admin-gated; an already-completed cycle answers the typed `cycle_not_active`. Returns the closed box, `moved_issue_ids`, `kept_issue_count` - `create_team {name, key?, issue_prefix?}` - admin-gated; `key` is the slug handle (derived from the name when absent); `issue_prefix` is the `ENG` in `ENG-42` (2-12 chars, letter-first, unique in the workspace - a collision answers a typed 422; omit for the workspace prefix). The same mint as `POST /api/v1/teams`; teams are never deletable - `update_team {team, name?, issue_prefix?}` - rename and/or re-prefix, addressed by key, name, or UUID; lead-grained (org admin or the team's own team admin). The prefix is the mutable re-skin: the team's refs wear the new prefix immediately WITHOUT renumbering; explicit `null` falls back to the workspace prefix. Audited (`team.renamed`, `team.issue_prefix_changed`) - `create_project {name, spec?}` - create the planning-layer container (starts `planned`; issues attach via `set_project`); `spec` is markdown - `update_project {project, name?, spec?}` - rename and/or re-spec, addressed by name or UUID (null `spec` clears); status/initiative/cycle links ride `PATCH /api/v1/projects/:id` ## Examples Every example needs a token (they 401 anonymous). Export one and the examples run verbatim: export SNECK_TOKEN=sneck_YOURTOKEN # minted in Settings → API tokens (scope api) curl -s 'localhost:4000/api/v1/issues?status=backlog&limit=2' \ -H "authorization: Bearer $SNECK_TOKEN" curl -s -X POST localhost:4000/api/v1/issues \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -H 'Idempotency-Key: 6f9619ff-8b86-d011-b42d-00cf4fc964ff' \ -d '{"title":"Ship the latch","priority":2}' # → 201 {"data":{"id":"","number":,"status":"backlog",...}} # then, with that id - comment with a mention, and read the thread back: curl -s -X POST localhost:4000/api/v1/issues//comments \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -d '{"body":"Deploy blocked on **staging** - @quinn_harbor can you look?"}' curl -s 'localhost:4000/api/v1/issues//comments?limit=10' \ -H "authorization: Bearer $SNECK_TOKEN" # sub-issues + time rollup on the same issue: curl -s -X POST localhost:4000/api/v1/issues//sub-issues \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -d '{"title":"Write the runbook"}' curl -s 'localhost:4000/api/v1/issues//time' \ -H "authorization: Bearer $SNECK_TOKEN" # plan one level up: initiative → project → issues, then report health # (fresh Idempotency-Key per logical request - keys are single-purpose) curl -s -X POST localhost:4000/api/v1/initiatives \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -H 'Idempotency-Key: 2c1d80b2-2f6f-4a6e-9d3f-6b4a9e5c1102' \ -d '{"name":"Fleet compliance","description":"Everything the auditors ask for, calmly."}' # → 201 {"data":{"id":"","health":"none","projects":[],...}} curl -s -X POST localhost:4000/api/v1/projects \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -H 'Idempotency-Key: 7a071aef-9c97-4f22-b53e-0f5a06d8750f' \ -d '{"name":"Telemetry pipeline","spec":"## Why\nClose the loop on fleet signals.","initiative_id":""}' # → 201 {"data":{"id":"","status":"planned","health":"none","rollup":{"count":0,...},...}} # then, with that project id - post a health report and watch it roll up: curl -s -X POST localhost:4000/api/v1/projects//updates \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -d '{"health":"on_track","body":"Spec intact; ingest shipping this cycle."}' curl -s 'localhost:4000/api/v1/initiatives/' \ -H "authorization: Bearer $SNECK_TOKEN" # → {"data":{"health":"on_track","projects":[{"name":"Telemetry pipeline","health":"on_track",...}],...}} # the liveness probe needs no token: curl -s localhost:4000/healthz curl -s -X POST localhost:4000/mcp \ -H "authorization: Bearer $SNECK_TOKEN" \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_issues","arguments":{"query":"latch"}}}' ## App surfaces (browser pages - no /api/v1 arms) These product features are LiveView pages only; there are no REST paths, no tokens, no payloads for them. Listed so agents don't hunt for endpoints that don't exist (docs and the notifications inbox grew REST arms - see their API-section bullets; their pages remain the browsing surfaces): - Docs - the wiki: `/docs` (home, grouped by project) and `/docs/{id}` (click-to-edit). Titled markdown pages, optionally filed under a project; org-prefixed issue refs (`SNECK-42`) linkify to the issue, and the issue detail links back every doc that refs it. The same context now also carries REST arms (`GET/POST/PATCH/DELETE /api/v1/docs`) - these pages remain the reading/editing surface. - Goals - the OKR layer: `/goals`. Goals own a manual status (`on_track|at_risk|off_track|done|dropped` - never derived); progress is always derived: numeric key results measure `(current-start)/(target-start)` clamped to 0..1, issues-kind key results read the done+canceled fraction of their linked issues. - Intake forms: managed at `/settings/forms`; the public door is `GET/POST /f/{token}` - no auth, the `sneckfrm_…` token (reveal-once, regenerating kills the old URL) IS the credential. Each submission files as a `backlog` issue; submitters never see issue ids. - Notifications center: `/notifications` - the per-user inbox page (mentions, assignments, subscriptions with watching/muted per issue). The API twin is `GET /api/v1/notifications` + its read-state POSTs - the page remains the browsing surface. - Bulk actions: multi-select on the working-set pages (`/list`, `/board`, `/table`, `/everything`, `/triage`) - set status/priority/assignee/due date, add/remove labels, move to team/project, archive, delete. Browser only. - Due dates: set on the issue detail (date picker); drive the calendar, timeline, and My Work DUE section. NOT writable via `/api/v1` (PATCH rejects `due_date` with `unknown_parameter`) and REST issue rows do not carry `due_date` - the MCP surface owns the agent arm: create_issue/update_issue's `due` writes it (the same set_issue_due_date door the picker uses, `null` clears) and MCP get_issue renders `due_date`. The `due:` fq key references it too. - The invite accept page: `GET /invites/{token}` - public, the mailed token is the read capability; accepting rides the session-authenticated app route. - Instance admin: `/instance-admin` (dashboard, mail, license, users, audit) - the operator console for `SNECK_INSTANCE_ADMINS` + the single-tenant owner; everyone else gets a calm 404. ## Optional - [Webhooks](https://gitlab.com/sneck-app/sneck/blob/main/README.md): `POST /webhooks/:provider/:connection_id` - signed forge webhooks (GitHub/GitLab/Gitea/Forgejo) - [Shapes](https://gitlab.com/sneck-app/sneck/blob/main/README.md): `GET /shapes/issues` - Electric HTTP shape API (org pinned server-side; client version pairing applies) - SCIM 2.0 provisioning: `/scim/v2/Users` + `/scim/v2/Groups` (RFC 7644) - for IdPs (Okta/Authentik), NOT agents: auth is the org's `sneck_scim_…` machine token minted in Settings → SSO, the envelope is RFC 7644's own (`application/scim+json`), and it is deliberately outside `/api/v1` and docs/openapi.yaml