API Reference¶
When the web server is enabled, the following endpoints are exposed:
| Endpoint | Description |
|---|---|
GET / |
Static dashboard UI. |
GET /api/tickets |
List all board tickets (JSON). ?all=true adds the ones whose status has no board column: archived, legacy closed, and any foreign status. |
POST /api/tickets |
Create a new ticket (JSON body: title, path, optional pipeline, agent, status, body, branch, base_branch, scheduled_at). A scheduled_at creates the ticket open and cannot be combined with another status; one that is malformed or already past answers 400. |
GET /api/tickets/{id} |
Get ticket details (JSON). |
GET /api/tickets/{id}/cost |
Estimate the cost of finished tracked runs from their activity sidecars and the bundled model-price catalog. |
DELETE /api/tickets/{id} |
Delete the ticket markdown file without worktree cleanup. Requires X-Kontora-Confirm: delete-ticket-file. Only deletes files inside tickets_dir. |
POST /api/tickets/{id}/pause |
Pause a running ticket. |
POST /api/tickets/{id}/retry |
Retry a paused ticket. |
POST /api/tickets/{id}/skip |
Skip the current pipeline stage. |
POST /api/tickets/{id}/set-stage |
Move ticket to a specific pipeline stage ({"stage": "..."} body). |
POST /api/tickets/{id}/schedule |
Set or clear the ticket's future pickup time ({"scheduled_at": "<RFC 3339>"} or {"clear": true} body). A scheduled_at that is malformed or already past answers 400. Clearing is refused only while the ticket is running. Answers the updated ticket. See scheduled pickup. |
POST /api/tickets/{id}/move |
Set ticket status ({"status": "..."} body). |
GET /api/tickets/archived |
The archived tickets, one row per ticket, for the Archive view. |
POST /api/tickets/{id}/archive |
Archive a closed ticket (optional {"note": "..."} body). |
POST /api/tickets/{id}/restore |
Return an archived ticket to the status it was archived from. |
GET /api/config |
Available pipelines, agents, and projects (JSON). agents remains the sorted agent-name list; agent_infos adds sorted {name, model, effort} metadata for each agent. Projects are sorted by name and carry name, path, resolved_path (~ expanded), pipeline, agent, and notify_channels. channels and default_channels are the configured notification channel names and notifications.default; names only, never a chat id or a webhook URL. |
GET /api/tickets/{id}/logs |
Get agent logs for a ticket (optional ?stage= query param). |
POST /api/tickets/{id}/summary |
Set the ticket's summary field ({"text": "..."} body). |
GET /api/tickets/{id}/changes |
Commits and changed files on the ticket's branch relative to its base_branch, or the repo's default branch when unset. Empty payload when the ticket has no branch or the branch was deleted. |
GET /api/tickets/{id}/chain |
The dependency chain through the ticket: everything it transitively waits on, itself, and everything that transitively waits on it. |
POST /api/tickets/{id}/init |
Initialize a non-kontora ticket (pipeline, path, optional agent, branch, status, notify, notify_channels). status is todo by default; open leaves the ticket a draft and keeps any scheduled_at it carries. See notifications for the two notify fields. |
POST /api/tickets/{id}/dep |
Make the ticket wait on another one ({"related": ["<id>"]} body, exactly one id). |
POST /api/tickets/{id}/undep |
Drop a dependency edge ({"related": ["<id>"]} body, exactly one id). |
POST /api/tickets/{id}/link |
Relate the ticket to each id in {"related": [...]}, on both sides. |
POST /api/tickets/{id}/unlink |
Remove the relation between the ticket and each id in {"related": [...]}. |
PUT /api/tickets/{id} |
Update an open ticket's body or frontmatter fields (body, pipeline, path, agent, branch, base_branch, notify, notify_channels). On the two notify fields an absent key leaves the ticket's own alone and [] removes it; a status nothing reaches or a channel nothing answers to is refused with 400. See notifications. |
POST /api/tickets/upload |
Import tickets from raw .md file content (multipart form). Requires X-Kontora-Confirm: upload-tickets. Every uploaded ticket arrives as an open draft: the status is clamped and any scheduled_at is dropped. |
POST /api/tickets/{id}/plannotator-review |
Open the ticket's branch diff in Plannotator. Only in human_review. Submitted feedback routes the ticket to the built-in rework stage. See plannotator. |
POST /api/tickets/{id}/plannotator-annotate |
Open the ticket's own markdown in Plannotator. Only in open. Submitted annotations set kontora: true and schedule a run that rewrites the ticket. |
GET /api/assistant |
Whether the assistant is configured: enabled, and when it is, agent, kind, model, autonomy and workdir. enabled: false carries a hint naming what to set, which is what the pane shows in place of its composer. |
GET /api/assistant/threads |
The chat history, most recently used first. |
POST /api/assistant/threads |
Open a chat (optional {"autonomy": "read"\|"ask"\|"auto"}; the configured default otherwise). Answers 201 with the chat. |
GET /api/assistant/threads/{id} |
One chat and the messages posted to it. |
DELETE /api/assistant/threads/{id} |
Drop a chat and its transcript. |
GET /api/assistant/threads/{id}/activity |
One poll of a chat: the transcript as a logfmt tape sliced at ?after=N, the messages, whether a turn is running, the message being written right now, and any change waiting on the user. Carries an ETag and answers If-None-Match with 304. |
GET /api/assistant/threads/{id}/stream |
Server-Sent Events stream of the message being written, pushed as it grows. Answers 204 when nothing is running and nothing was typed. |
POST /api/assistant/threads/{id}/messages |
Post a message ({"text": "...", "autonomy": "..."}). Answers 202: the reply arrives through the activity poll. |
POST /api/assistant/threads/{id}/stop |
Cancel the turn the chat is running. |
POST /api/assistant/gate/{gid} |
Answer a change the assistant is waiting on ({"decision": "approve"\|"skip"}). |
POST /api/assistant/gate/ask |
The agent side of the tool gate, called by the claude PreToolUse hook and the pi tool_call handler. Authenticated by the per-turn secret in the agent's environment, not by the web token alone. Blocks while a change waits on the user. |
GET /api/events |
Server-Sent Events stream of ticket updates. |
GET /ws/terminal/{id} |
Read-only WebSocket relay of a running agent's tmux session. |
GET /health |
Health check (returns 200). |
Each agent_infos entry in GET /api/config reports the model and effort that Kontora detects. Kontora reads the agent's effort field and supported CLI flags. Pipeline and stage overrides are not applied. Both response fields are always present. An empty value means Kontora detected no setting.
Cost estimates¶
GET /api/tickets/{id}/cost returns the known ticket subtotal and its coverage:
{
"id": "kon-example",
"cost_usd": "0.012345",
"priced_runs": 2,
"tracked_runs": 3,
"stages": [
{"name": "implement", "cost_usd": "0.012345", "priced_runs": 2, "tracked_runs": 3}
],
"runs": [
{
"history_index": 0,
"stage": "implement",
"run": 0,
"model": "openai-codex/gpt-5.6-sol",
"model_source": "sidecar",
"sidecar_model": "openai-codex/gpt-5.6-sol",
"history_model": "openai/gpt-5.6-sol",
"resolved_model": "openai/gpt-5.6-sol",
"cost_usd": "0.012345"
},
{"history_index": 1, "stage": "implement", "run": 1, "cost_usd": null}
]
}
Every cost_usd is a decimal US-dollar string or null. A measured free run
returns "0.000000". An unpriced run returns null. A ticket or stage with
partial coverage returns its known subtotal and reports fewer priced_runs
than tracked_runs. Its subtotal is null only when none of its runs can be
priced. sidecar_model and history_model preserve the source values when
present; model is the value chosen for resolution. An unknown ticket returns
404.
Run sidecars are matched by their position among all rows for a stage. This also
covers old History rows and annotation rows whose stored run field does not
identify the sidecar. history_index addresses the row in the ticket's History
array. It is null for the synthetic default run of an ended standalone
ticket, which has a sidecar but no History row.
Kontora calculates estimates from fresh input, output, cache-read, and cache-write tokens. It uses the sidecar model first and the History model as a fallback. Kontora leaves a run unpriced if:
- usage is missing or partial;
- the stage name is empty,
.,.., or contains/or\; - the model is unknown;
- a token count is negative; or
- a used token category has no rate.
The bundled catalog is a snapshot of OpenRouter's Models API. Model pricing data is provided by OpenRouter. The snapshot uses standard top-provider text-token rates and ignores conditional long-context, time-window, request, tool, and media prices. Kontora applies the snapshot in the current binary to old runs, so an estimate can change after an upgrade and is not a bill.
The estimate excludes calls that do not write complete activity sidecars. This includes final-summary runs, the Plannotator subprocess, and nested Pi tool calls. If a sidecar records a model change, Kontora prices the entire run at the final recorded model because the sidecar stores one run total, not totals per model.
Maintainers can refresh the committed offline snapshot with:
The command fetches the OpenRouter Models API, validates and sorts the retained
records, then replaces internal/pricing/catalog.json atomically. Builds and
tests never fetch the catalog.
The message being written¶
GET /api/assistant/threads/{id}/activity carries three fields for the message
the agent is writing before its session file records it: partial, the text so
far; partial_gen, bumped when a new block starts, so a reader replaces rather
than appends; and partial_tool, the tool call whose arguments are still being
generated. partial is empty once the tape carries the same words, and the
response that empties it is the one that carries them, so nothing renders twice
and nothing blanks between the two. A turn that ended without recording its
message keeps its partial alongside running: false, since that copy is then
the only one there is. The text is held in daemon memory only: it is never part
of the logfmt tape, which is the parsed form of a file.
GET /api/assistant/threads/{id}/stream pushes the same text at roughly ten
frames a second, so a reader sees it as typing rather than in 1.5s steps. It is
an enhancement over the poll: a client that cannot open it still renders growing
partial text from successive polls.
event: reset data: {"gen":3,"text":"<everything so far>","tool":""}
event: delta data: {"gen":3,"text":"<suffix since the last frame>"}
event: tool data: {"gen":3,"name":"Bash"}
event: end data: {} # the block stopped growing; the text stays
event: done data: {} # the turn is over and the server is closing
: keepalive # after 20s of silence
A reset arrives on connect and again whenever gen changes; a client ignores
a delta whose gen it does not hold. The connection answers 204, rather than
opening, when nothing is running and nothing was typed: EventSource does not
retry a 204, so a stale pane stops asking. Like GET /api/events, the response
is never gzipped.
On POST /api/tickets and POST /api/tickets/{id}/init, a blank pipeline or agent takes the default of the project matching path. Send the literal none to leave that field blank and skip its project default. PUT /api/tickets/{id} reads none the same way, as "clear this field"; it has no project default to skip.
On POST /api/tickets/{id}/init, a blank pipeline or agent first falls back to what the ticket file already declares, so the project default fills an empty field instead of replacing the ticket's own value.
Both Plannotator endpoints return 202 once the session is accepted, and report its outcome over GET /api/events as a plannotator_finished event. They return 404 for an unknown ticket, 409 when a Plannotator process is already open for the ticket or its status does not allow the requested pass, and 500 when the plannotator binary is not installed. GET /api/tickets and GET /api/tickets/{id} carry can_annotate, which answers the same status rules the annotate endpoint enforces.
Relations ride both ticket payloads as arrays of {id, title, status}. GET /api/tickets carries the id alone; GET /api/tickets/{id} and the SSE ticket updates also carry the title and status of every ticket the daemon still has on disk, and add the two derived reverse edges: blocks, the tickets whose deps name this one, and children, the tickets whose parent names it. Both are sorted by id and absent from the list payload. A ref with no title and no status names a ticket that is not in tickets_dir. See Relations.
GET /api/tickets/{id}/chain is the derived view of the same edges. It walks the whole store, so archived tickets and statuses with no board column stay in the graph. nodes is sorted roots to goal with depth monotonic non-decreasing, and every upstream node comes before the ticket itself, which comes before every downstream node. Each node carries depth, direction (upstream, self or downstream), on_critical_path, holds_chain, waits_on ({open, total} over that node's own deps, including the deps outside the chain) and missing. At most one node has holds_chain set: the node the chain waits on. While a dep blocks the ticket that is an unresolved dep upstream with no unresolved dep of its own, so it names work that can start now; while nothing blocks the ticket it is the first unresolved node from the ticket onward along the critical path, which is the ticket itself until it closes. A chain whose nodes are all resolved has no such node.
verdict is blocked, ready or cycle. It reports the deps alone rather than the ticket's own status, so a done ticket with closed deps reads ready. position, path_length and goal place the ticket on the critical path. On cycle the ids on the cycle are in cycle, nodes is empty, and position, path_length and goal are zero: a graph with a cycle has no order to draw. total is the true node count and nodes is capped at 200, so a total larger than the length of nodes means the payload was trimmed; the critical path is kept whatever the cap. done counts the nodes that no longer block, which includes the ones in human_review and the cancelled and archived ones. A node whose id names no ticket file has missing: true with an empty title and status. An unknown ticket id returns 404.
A children entry carries more than the other relation arrays, so a sub-ticket row renders without another request: stage, stage_index and stage_count for the child's position in its own pipeline, and started_at and completed_at for its wall time. stage_index is 1-based and absent when the child's stage is not in its pipeline. The timestamps bound the child's whole run, first pickup to last exit, rather than the current stage. completed_at is absent while the child is in_progress.
Both ticket payloads carry project, the configured project whose path is the ticket's, and the derived readiness: ready when no dependency holds the ticket back, and blockers naming the dependency ids that do when one does. Neither is stored; see dependency-aware scheduling. POST /api/tickets/{id}/run answers with the ticket, so a caller can see from blockers whether the ticket it just moved to todo will actually be picked up.
The four relation endpoints take the same body, {"related": [...]}, and answer with the changed ticket. They return 400 for an empty list, an empty id, or more than one id on the two dependency verbs; 404 for an id no ticket answers; and 409 when the ticket is related to itself or when a dependency would close a cycle, with the cycle named in the error. A rejected call writes no file. Repeating a call that has nothing left to do returns 200 and writes nothing.
A link is written to both tickets, one file at a time, because two markdown files cannot be written together. When the second write fails the error names both tickets and which one was already changed, and repeating the request repairs the missing side.
Archive¶
POST /api/tickets/{id}/archive sets status: archived and writes four frontmatter fields: archived_from, the closed status the ticket held; archived_at; archived_by, which is web here and sweep for kontora archive; and archive_note when the body carried one. It answers 409 unless the ticket is done, cancelled or the legacy closed, and writes no file when it refuses. An archived ticket keeps its file, its branch and its logs, and leaves GET /api/tickets โ ?all=true still lists it, and GET /api/tickets/{id} still answers it, with the four fields on the payload.
POST /api/tickets/{id}/restore writes archived_from back into status and removes all four fields. A ticket archived before those fields existed, or one whose archived_from names a status that is no longer a board column, restores to done rather than staying stranded in archived. It answers 409 for a ticket that is not archived.
GET /api/tickets/archived answers {"tickets": [...]}. Each row carries id, title, project, pipeline, agent, branch, path, archived_at, archived_by, a status holding the ticket's archived_from, and wall_seconds: the interval from the first history entry's started_at to the last completed_at, so it spans every run rather than the newest one. A ticket with no archived_at reports its file mtime instead, which is the date the sweep's own cutoff uses. The path is more specific than GET /api/tickets/{id}, so a ticket whose id is literally archived is unreachable by that one path; every other route still reaches it.
A ticket whose branch is empty carries auto_branch in GET /api/tickets and GET /api/tickets/{id}: the branch the daemon would assign at pickup, resolved for the path the ticket names and the current branch naming mode. It is a read-only projection, not a stored field, and it is absent once branch is set.
The assistant endpoints answer 501 when no assistant.agent is configured, 404 for an unknown chat, and 409 for a second message on a chat whose turn is still running. A chat keeps the agent it was created with, so a message to one whose agent has been repointed or removed also answers 409: its agent session cannot resume on another CLI, and the chat has to be started again. 503 is the separate refusal for the daemon's global turn cap, which is not about this chat at all. POST /api/assistant/gate/{gid} answers 404 once the change has already been answered or the turn that raised it has ended, so a stale card cannot resolve twice. POST /api/assistant/gate/ask answers 403 when the secret does not match the chat's current turn, which is what stops an unrelated local process approving its own writes against a tokenless loopback daemon.
Notes¶
A ticket's notes live in the ## Notes section of its markdown body, so a stage prompt that interpolates the body carries them. See Notes for the on-disk format.
| Endpoint | Description |
|---|---|
POST /api/tickets/{id}/note |
Append a note ({"text": "...", "author": "...", "parent": "<note-id>"}; author and parent optional). |
PATCH /api/tickets/{id}/notes/{noteID} |
Replace one note's text ({"text": "..."}). The note's byline gains the edited flag. |
DELETE /api/tickets/{id}/notes/{noteID} |
Delete one note and its replies. |
POST /api/tickets/{id}/notes/{noteID}/reactions |
Add one actor's reaction ({"emoji": "๐", "actor": "..."}; actor optional). |
DELETE /api/tickets/{id}/notes/{noteID}/reactions/{emoji} |
Drop it again. The emoji is percent-encoded in the path (๐ is %F0%9F%91%8D); an optional ?actor= names whose reaction to drop. |
All five answer 200 with the whole ticket, so a caller replaces its copy rather than reconciling, and all five broadcast a ticket_updated event.
Every note the daemon writes carries a 4-character id, unique within the ticket, which is what {noteID} names. A note written before the format carried one is addressed as #<index> โ its position in notes โ and acting on it mints a real id in the same write. An id no note carries answers 404.
An empty author signs as the daemon's configured author, and so does an empty reaction actor. An author containing ยท, a newline or ** is rejected with 400: the separator is what holds the byline together.
A parent makes the note a reply. One level only: a parent naming a note that is itself a reply answers 409 and writes nothing. Deleting a note deletes its replies with it.
Notes ride GET /api/tickets/{id} and the SSE ticket_updated event as notes, and are absent from GET /api/tickets, which carries no body. Each entry is {id, at, author, author_kind, parent_id, edited, text, reactions}. at is the byline's first field: an RFC3339 timestamp for a note Kontora wrote, and whatever the author typed for a hand-written one. author_kind is derived from the config, not stored โ system for kontora, agent for an author a configured agent is named after, human for anything else, and absent for a note with no author.
Reactions are stored in <tickets_dir>/<ticket-id>.notes.json, never in the ticket body, so the body stays readable to the agent that receives it in a stage prompt. The sidecar is invisible to ticket listing and to the file watcher, a missing one reads as no reactions, and DELETE /api/tickets/{id} removes it with the ticket file. A ticket copied without its sidecar keeps its conversation and loses its reactions.
Deleting a note needs no X-Kontora-Confirm header. That guard exists for the ticket file, whose deletion is unrecoverable across the whole store.
Base branch validation¶
base_branch names the branch a ticket's worktree is cut from. See Base branch for what the field means.
Both POST /api/tickets and PUT /api/tickets/{id} check the name's format and return 400 with invalid base branch name when it is malformed. Only creation checks that the branch exists, and it fails before writing the ticket file. A create request with "status":"open" skips that check, because an open ticket is not ready to run. An update accepts a name that resolves to nothing, because the repository at the ticket's path can change afterwards; the failure surfaces when the daemon builds the worktree and pauses the ticket.
The format check accepts any valid git branch name, which includes tag-shaped names such as v1.0. Restricting the base to real branches happens during resolution, not during format validation.