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, kind, parent). 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}/run |
Queue a ticket. On a passive epic, explicitly activate a supervisor. See Epics. |
POST /api/tickets/{id}/pause |
Pause a running ticket. On an agent epic, stop only its supervisor; running children continue. |
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": [...]}. |
POST /api/tickets/{id}/parent |
Set a child's epic ({"parent":"EPIC_ID"}). Agent epics require the same repository. |
POST /api/tickets/{id}/unparent |
Clear a child's parent. |
PUT /api/tickets/{id}/children |
Set an epic's display order ({"children":["CHILD_ID"]}). Does not change membership or execution order. |
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/companion |
Companion health, revision, current attention and recent changes. Available without assistant.agent. |
PATCH /api/assistant/companion |
Record an explicit caught-up checkpoint ({"revision": 12}). |
PATCH /api/assistant/companion/items/{id} |
Set shared read state using the item's revision ({"revision": 7, "read": true}). |
POST /api/assistant/companion/messages |
Request a briefing, investigation or follow-up. See Companion. |
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 an ordinary chat message ({"text": "...", "autonomy": "..."}). Answers 202: the reply arrives through the activity poll. Companion-purpose threads reject this route. |
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 and companion invalidations. |
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.
Epics¶
POST /api/tickets with kind: "epic" creates a passive epic in open.
Creation never starts an agent. A passive epic can store agent and branch settings,
but has no pipeline, worktree or run history. Their status summarizes children, and their brief stays
editable in every status. Epics cannot nest.
POST /api/tickets/{id}/run explicitly activates a passive epic. Initialization
with explicit {"status":"todo"} also activates it; omit pipeline and stage.
Activation writes epic_execution: "agent", resolves the supervisor agent and
queues the epic. The epic and every child must use the same Git repository.
Linked worktrees and symlink paths to that repository are allowed. Passive epics
can group different repositories until activation. Draft edits never activate them.
Ticket payloads include kind, epic_execution, branch and base_branch.
epic_execution: "agent" distinguishes an activated epic from passive grouping.
child_order is the stored display order. Detail payloads also include the
derived children array; membership comes from each child's parent.
The CLI's ls --json exposes these fields too.
The supervisor runs in the epic's own branch and worktree. Children retain their
own pipelines, branches, worktrees and commits. Pause stops only the supervisor;
running children continue. Agent epics use ordinary edit restrictions while
running or terminal. Successful supervisor exit parks the epic in human_review,
never directly in done. Kontora requires a nonempty child set, with every child
done, legacy closed or human_review. Cancelled and archived children do not
pass. This does not prove integration, verification claims or human acceptance.
Plannotator reviews the combined epic diff; feedback starts supervisor rework.
The epic records supervisor history, summaries, logs, activity and sessions.
Supervisors use max_concurrent_epic_supervisors, default 4, separately from
worker capacity. This permits four additional agent processes. Dependencies
control scheduling, not the commits present in a child's Git base. Activation
does not grant push, publication, destructive reset or shared-history permissions.
Existing approval requirements and off-machine write restrictions still apply.
See Epics for integration rules.
Upgrade every daemon and client sharing the ticket directory before activation. Older versions treat all epics as passive and can overwrite supervisor status. Do not run mixed versions against activated epics.
Companion¶
The companion endpoints use the same authentication as the other web endpoints.
GET /api/assistant/companion returns a snapshot with:
enabled,name,ai_availableand a configurationhintwhen needed;healthand anerrorwhen storage is degraded;revision, the snapshot revision, andcaught_up, the saved checkpoint;observed_at, the last observation time;items, the current attention and retained inactive records, including read state and item revisions.
The inbox and read state are shared across devices, not isolated per person.
The snapshot remains available without a configured model agent. Only requested
AI actions need assistant.agent.
PATCH /api/assistant/companion requires {"revision": 12}, using the snapshot
revision the user explicitly caught up to.
PATCH /api/assistant/companion/items/{id} requires {"revision": 7, "read": true}
or read: false, using that item's revision. Both return the updated snapshot.
If an item revision is stale, refetch before retrying. Neither operation changes a
ticket, answers a question or completes a review. Storage failures refuse
updates that cannot be saved; task execution and notifications continue.
POST /api/assistant/companion/messages accepts:
kind must be briefing, investigation or followup.
text must be non-blank and at most 32 KiB (32,768 UTF-8 bytes).
ticket_ids accepts at most five tickets. An investigation requires at least one; other request kinds can omit it. Optional thread_id
continues a companion-purpose thread; omitting it creates one. An accepted
request returns 202 with the thread, including its ID. Use the existing thread,
activity, stream, Stop and delete endpoints for the conversation.
Kontora builds evidence server-side and saves it before starting the model. Each request has a 32 KiB evidence limit. Capture metadata includes times, source revisions, observation windows, omissions and an outdated marker when sources change. Metadata remains available during streaming and after reopening the chat. Historical explanations do not change current attention.
Companion threads have immutable purpose: "companion". The ordinary message
route rejects them, so clients cannot bypass evidence capture. Companion turns
deny every tool call and autonomy escalation. They cannot mutate tickets,
execute approvals or send outbound messages. Enforcement uses runtime hooks
with a trusted configured agent, not an OS sandbox.
After successful persistence, GET /api/events sends a payload-free
companion_updated invalidation. Clients must refetch the snapshot on connection,
reconnection, focus and a periodic fallback. The event stream is not an inbox
history. Startup, observation, reload and reconnection never start model turns.
The store retains all active items plus at most 500 inactive records, each no older than 30 days. Degraded health means the store cannot provide normal persistence guarantees. Disabling the companion on reload retains data and blocks new requests; an already-running requested turn remains stoppable. Polling can miss short-lived transitions, and offline events are not a complete history. A disappeared question is not proof that someone answered it. See companion configuration.
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. blocks is sorted by id. Epic children follow the stored display order, then creation order. Both are 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.
Assistant model-turn endpoints answer 501 when no assistant.agent is configured.
The companion snapshot and inbox updates do not require one.
Requests for an unknown chat return 404.
Posting another message to a chat whose turn is still running returns 409.
A chat keeps its original agent. Repointing or removing that agent makes later
messages return 409; an agent session cannot resume on another CLI. Start a new
chat instead. The daemon's global turn cap returns 503, independently of any
single chat.
POST /api/assistant/gate/{gid} answers 404 after the change is answered or its
turn ends, 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. This prevents
unrelated local processes from approving their 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.