Skip to content

Tickets & boards#

Tickets are the unit of work. Each ticket belongs to exactly one project, carries a type (epic, story, or subtask), lives in one workflow state at a time, and has a human-readable display id like INFRA-42.

This page covers the day-to-day operator surfaces: creating tickets, editing them, moving them across the board, working the backlog, and searching.

The ticket model in 90 seconds#

Field Purpose Editable on the detail page?
display_id {PROJECT_KEY}-{counter}, e.g. INFRA-42. Allocated atomically on create. No — server-owned, permanent.
type epic, story, or subtask. Drives the hierarchy rules. No — set on create, fixed after.
title Short summary. Yes
body Markdown description. Yes
state Current workflow state. Yes — but via the transition endpoint, not a plain PATCH.
priority low, medium, high, urgent. Yes
assignee A human or bot user. Yes
reporter The creator. No — set on create.
parent Another ticket; hierarchy rules apply. Yes
labels Many-to-many with project labels. Yes
watchers Users subscribed to the ticket's activity feed. Yes
due_date Date only (no time). Yes
closed_at Server-maintained when the ticket enters/leaves a done-category state. No — derived.

Hierarchy rules#

epic
  └─ story
      └─ subtask
  • Epics may not have a parent. Stories may have an epic parent. Subtasks must have a story parent.
  • The API rejects invalid combinations with 422 validation.invalid_field on create, and with 409 resource.conflict if you try to reparent into an illegal configuration via PATCH.

The hierarchy is shallow on purpose. If you want deeper structure, use labels and the backlog filters rather than nesting.

Creating a ticket#

From the board#

  1. On /projects/{KEY}/board, click the + on any column header.
  2. A dialog opens, pre-filled with that column's state.
  3. Fill in title (required), type, priority, parent (optional, with type-aware search), assignee (optional), labels (optional), due date (optional). The body editor accepts markdown.
  4. Submit. The card appears in the column and the dialog closes.

From the backlog#

/projects/{KEY}/backlog has the same form, accessible from the top + New ticket button. The state defaults to the project's default state (the column marked with a star on the workflow settings).

From a sticky note#

If the idea started as a sticky note, click Convert to ticket on the note. See Sticky notes.

From the API (or a bot)#

curl -s -X POST http://localhost/api/v1/projects/$PROJECT_UUID/tickets \
  -H "Content-Type: application/json" \
  -H "X-Requested-By: web" \
  -H "Idempotency-Key: $(uuidgen)" \
  -b "tb_session=$YOUR_SESSION" \
  -d '{
    "type": "story",
    "title": "Add user-search to backlog filters",
    "body": "Need to filter by assignee from the URL.",
    "priority": "medium"
  }'

Returns 201 Created plus a Location: /api/v1/tickets/INFRA-42 header. The display id is allocated atomically; the next ticket created in the project will be INFRA-43, with no gaps unless a creation transaction rolled back.

The Idempotency-Key header is optional but strongly recommended for scripted or bot-driven creates; on retry with the same key and identical body the server replays the original 201 response. A retry with the same key and a different body returns 409 resource.conflict (reason=idempotency_mismatch). See spec/02_api_design.md#idempotency.

Editing a ticket#

The ticket detail page (/tickets/{display_id}) is where most editing happens. The page accepts PROJ-123-style display ids as the path segment; the UUIDv7 is never shown.

Inline edits#

Field Pattern
Title Click → inline input → blur saves. ESC reverts.
State Select dropdown → on change, calls POST /tickets/{id}/transition.
Assignee Avatar + picker → on change, calls PATCH /tickets/{id}.
Priority Pill + dropdown → PATCH.
Due date Date picker → PATCH.
Labels (side panel) Multi-select → PATCH.
Watchers List + add button → POST/DELETE /tickets/{id}/watchers.
Description body Markdown editor with preview tab. Explicit Save button. No auto-save.
Comments Reply form at the bottom of the activity feed.

The description body is the only field that does not auto-save; descriptions are long-form and auto-saving inside a preview tab would surprise you.

Why transitions are separate#

State changes go through POST /tickets/{id}/transition, not the generic PATCH. Two reasons:

  1. Side effects. Entering or leaving a done-category state maintains the closed_at field. The transition endpoint is the only path that does this correctly.
  2. Optional comment. The transition endpoint accepts a comment_body parameter so you can move a ticket and explain in one operation. The created comment is attributed to the same actor and same request.

PATCH /tickets/{id} rejects attempts to change state_id, type, display_id, or ticket_number with 422 validation.invalid_field.

ETags and concurrent edits#

GETs on a ticket return a ETag: W/"<updated_at_ms>-<short_id>" header. The UI sends If-Match on PATCH and DELETE; if the ETag is stale (someone else edited the ticket between your read and your write), the server returns 409 resource.conflict (reason=etag_mismatch) and the UI shows a "Someone else edited this. Reload?" modal.

If-Match is opt-in: quick toggles (state change, assign) skip it; the body editor uses it. If you script ticket edits, consider sending If-Match for anything you wouldn't want to silently overwrite.

The board#

Route: /projects/{KEY}/board.

Each column is a workflow state, ordered left-to-right by position. Each card shows: title, display id, type pill, assignee avatar, label dots, due date.

Drag and drop#

Cards drag between columns. The drop fires POST /tickets/{id}/transition with the target column's state id; the UI optimistically moves the card and reverts on failure. A failed transition raises a toast explaining why.

Drag and drop has a keyboard alternative: every card has a Move to… menu (focusable, ARIA-labelled). Use the keyboard if your mouse hand is busy.

Filters#

The board's filter bar reflects state in the URL — every filter is shareable as a link:

Filter URL param
Assignee ?assignee=<user_id>
Label ?label=<label_id> (multi)
Type ?type=epic / ?type=story / ?type=subtask (multi)
Text query ?q=...
Group by epic ?group=epic

group=epic adds a row dimension: each row is one epic (plus a "No epic" row at the bottom). Within each cell you still get vertical stacks of tickets and the same drag-between-columns behavior. Cross-row drags are not supported in v1 — change a ticket's parent via the detail page.

Performance#

Columns with more than 100 tickets virtualize their list. The drag overlay uses a portal so virtualization scrolling doesn't break the drag. If you have a column that's getting heavy, that's usually a signal to triage rather than tune; use the backlog with filters instead.

The backlog#

Route: /projects/{KEY}/backlog.

The backlog is a flat list of every non-deleted ticket in the project. Use it when the board's "current state" view isn't what you need — sorting by due date, filtering by label, or batch-reading new content.

Sort and filter#

Filters mirror the board, with extras:

Filter URL param
Project (implicit — the URL)
Type ?type=epic\|story\|subtask
State ?state_id=<uuid> or ?state_category=backlog\|active\|done
Assignee ?assignee_user_id=<uuid>
Reporter ?reporter_user_id=<uuid>
Label ?label_id=<uuid>
Parent ?parent_ticket_id=<uuid>
Priority ?priority=low\|medium\|high\|urgent
Due ?due_before=<iso8601> / ?due_after=<iso8601>
Updated since ?updated_since=<iso8601>
Text query ?q=...
Sort ?sort=-updated_at (or any field; - for descending)

All list endpoints in Ticket Board use cursor pagination — there are no page numbers. The UI shows a Load more button at the bottom of the list; the cursor is opaque base64 you don't need to think about.

The cross-project listing#

GET /api/v1/tickets (without a project scope) returns tickets across every project the actor can access. Bots see only their allowlisted projects; humans see everything. This is the endpoint behind the "Assigned to me" widget on the dashboard.

Comments and the activity feed#

The bottom half of every ticket detail page is the Activity section: a combined stream of comments and other events, oldest first.

Comments#

Markdown. Editable by the author. Soft-delete preserves the body and edit history in the audit event so deletion isn't a data-loss path.

Action What happens
Add Renders as a comment.created activity event. The author becomes a watcher automatically (subscribed_via='auto_commenter').
Edit Previous body appended to edit_history. The activity event shows "edited a comment".
Delete Soft-delete. The body is hidden from the UI but persists in the audit event payload. Hard purge is not exposed; the audit row carries the content forever.

Only the author can edit or delete their own comments. Humans cannot edit a bot's comment via the UI; if you really need to redact a bot's comment, soft-delete it and add a new one explaining.

Activity events#

The same feed shows non-comment events: state transitions, assignment changes, label adds/removes, priority changes, due-date changes, parent reparenting, relation adds/removes. The full list is in spec/07_observability.md#ticket-domain.

The feed is computed by querying audit_event for ticket_id=X AND category='activity'. There is no separate activity table; activity is a filtered projection of audit. See Audit log for what that means in practice.

Polling#

The feed polls every 30 seconds when the tab is visible and pauses when it's hidden (no WebSockets in v1). Mutations you initiate refresh the feed immediately without waiting for the poll.

Relations#

Tickets can be linked with typed edges:

Type Meaning Reverse-rendered as
blocks Source blocks target "blocked by"
relates_to Symmetric "see also" "relates to"
duplicates Source duplicates target "duplicated by"

The Relations panel on the ticket detail page lists incoming and outgoing edges with click-through links. Adding a relation:

  • From the UI: click + Add relation, search for a ticket by title or display id, pick the relation type, submit.
  • From the API: POST /api/v1/tickets/{id_or_display_id}/relations with {target_ticket_id, relation_type}.

Relations are hard-delete: removing one is permanent. The audit log captures both add (ticket.relation_added) and remove (ticket.relation_removed).

Watchers#

Watchers are users subscribed to a ticket's activity feed. The subscribed_via field records why they were added:

Value Meaning
manual Added by a human or via the API.
auto_assignee Auto-added when assigned.
auto_commenter Auto-added when they commented.
auto_reporter The original creator.

Watcher subscription is informational in v1 — no notifications are sent. Watching a ticket simply means it shows up in any future "tickets I'm watching" widget and that you're recorded as an interested party in the audit trail. Removing yourself as a watcher is fine; the audit record persists.

Deleting a ticket#

DELETE /api/v1/tickets/{id_or_display_id} performs a soft-delete:

  • deleted_at is set on the row.
  • The ticket disappears from board, backlog, and search by default.
  • Children (subtasks of a deleted story, for example) are not cascaded — they continue to exist but their parent reference now points at a deleted row.
  • Activity history (comments, transitions) survives in audit_event.

Humans can browse soft-deleted tickets via ?include_deleted=true on read endpoints. Bots cannot. There is no public "restore" endpoint — restoration is a direct database update via the CLI; see Projects → Restoring a soft-deleted project for the same pattern applied to tickets.

The topbar Search opens /search. The endpoint is GET /api/v1/search?q=<query> and returns mixed results across tickets and wiki pages, ranked by ts_rank_cd:

  • Title matches outrank body matches (title gets A weight, body gets B).
  • Ticket comments contribute to the ticket's rank (C weight), so a discussion thread can pull a ticket into a results list whose title and body don't mention the query.
  • Soft-deleted tickets and wiki pages are excluded.
  • Bots see results filtered by their project allowlist (plus all global wiki pages). Humans see everything.

The query is passed to Postgres plainto_tsquery('english', q) — phrase queries, boolean operators, and field qualifiers are not supported in v1. If you need stricter queries, the backlog filters are more useful.

Searching from the backlog#

The backlog's ?q=... filter is the same FTS engine scoped to one project. Use this when you know the ticket is in a specific project; use the topbar search when you don't.

API surface recap#

For scripting against the ticket surface — bots, automations, manual debugging — the endpoints you'll touch most often:

Endpoint Verb Purpose
/api/v1/projects/{id}/tickets POST Create a ticket in a project.
/api/v1/tickets GET Cross-project list with filters.
/api/v1/projects/{id}/tickets GET Same, project-scoped.
/api/v1/tickets/{id_or_display_id} GET Fetch by UUID or PROJ-123.
/api/v1/tickets/{id_or_display_id} PATCH Update mutable fields (not state).
/api/v1/tickets/{id_or_display_id}/transition POST Move to another state, optionally with a comment.
/api/v1/tickets/{id_or_display_id} DELETE Soft-delete.
/api/v1/tickets/{id_or_display_id}/comments GET/POST List or add comments.
/api/v1/tickets/{id_or_display_id}/relations GET/POST List or add relations.
/api/v1/tickets/{id_or_display_id}/watchers GET/POST/DELETE Manage watchers.

Full details (filters, error codes, ETag handling, idempotency) are in spec/02_api_design.md#tickets.

Next#

  • Capture longer-form context with the Wiki.
  • Use Sticky notes for quick capture that may become tickets.
  • If a bot will create or update tickets on your behalf, set up Bots.