Skip to content

API reference

Pages
On this page

Every route Hatch answers lives under /api/hatch, JSON in and JSON out. This page groups them by resource: the board's own resources first, then the loop's (cli-reference is the easier way to make most of these calls; this page is for the ones it has no verb for).

How to call it#

A person's browser carries a session cookie; a program sends Authorization: Bearer hatch_ak_…. People, roles and keys covers minting a key and what its one scope reaches - this page only says, route by route, who may call it.

Issue routes take the display key - /issues/{key}, case-insensitively, never a database id. Project and column routes take an int id - /projects/{id}, /statuses/{id}. A person id is a guid - /projects/{id}/members/{personId}.

hatch api GET /api/hatch/issues?statusId=2

cli-reference has hatch api in full.

docs/hatch-planning.md ships inside every Hatch image, so a session can read it on the machine it is working on. It carries the filing shapes in full - parentKey, readyAt/dueAt, the issues filters, /issues/bulk and the /plan reads - none of which are repeated here.

Projects#

Method Path Who What it does
GET /projects key Every project, with issue count, repositories, logo timestamp, members, canApprove/canAdminister for the caller
POST /projects key Creates one; the creating person (if any) becomes owner
PATCH /projects/{id} key Renames and/or rekeys; both optional, null leaves each alone
DELETE /projects/{id} key Deletes an empty project
GET /projects/{id}/repositories key The bound remotes, in order, primary first
PUT /projects/{id}/repositories person Replaces the whole ordered list
GET /projects/{id}/logo key The image bytes
PUT /projects/{id}/logo person Sets it (raw body, detected content-type)
DELETE /projects/{id}/logo person Clears it
GET /projects/{id}/members key Who owns/approves
PUT /projects/{id}/members/{personId} person Sets one member's role, adding them if new
DELETE /projects/{id}/members/{personId} person Removes a member
POST /projects/{id}/claim person Takes ownership of a project with none
PUT /projects/{id}/landing person Sets landing, checksRequired, onePullRequest default, and the four autoHealBug* fields
PUT /projects/{id}/playbooks-source person Toggles repoSourced
PUT /projects/{id}/reflection person Sets reflectionCadenceDays
POST /projects/{id}/reflect person Files one reflection on the spot - the reflect-now control
PUT /projects/{id}/runner-tags person Replaces the whole list of runner tags

ProjectCreateRequest is Key, Name, an optional Color and Icon. ProjectPatchRequest is the same four fields, all optional - null leaves a field alone on the patch, "" clears Color/Icon. The repositories PUT body is a list of ProjectRepositoryWriteRequest(Remote, BaseBranch?) - the whole ordered set, not a delta. A members PUT body is ProjectMemberWriteRequest(Role), "owner" or "approver". The landing PUT body is ProjectLandingWriteRequest: Landing?, ChecksRequired?, OnePullRequest?, AutoHealBugEnabled?, AutoHealBugPriority?, AutoHealBugPreApproval?, AutoHealBugStatusId? - AutoHealBugStatusId is the one field with a clear sentinel, "". The playbooks-source PUT body is ProjectRepoSourcedWriteRequest(RepoSourced). The reflection PUT body is ProjectReflectionWriteRequest(CadenceDays) - a positive number of days, or null to turn reflection off; it never writes reflectionLastPeriodStart, only the reflection job does. POST .../reflect takes no body, and answers an IssueDto - the reflection it just filed, or, pressed again while that one is still open, the same issue with nothing filed; it never writes reflectionLastPeriodStart either, only the job does. The runner-tags PUT body is ProjectRunnerTagsWriteRequest(Tags) - the whole list, not a delta. Answers are a ProjectDto throughout, and a list of ProjectMemberDto from the member and claim routes.

Refusals worth naming. DELETE a project 409s if it still holds issues, and separately 409s if a playbook still names it. A members PUT 403s if the caller is not a live owner, 400s on an unknown role or person, and 409s if the edit would leave the project with no owner at all - the one state only /claim escapes. POST /claim 409s if the project already has an owner. The landing PUT 403s if the caller is not an owner; landing is "pull-request" or "trunk", anything else 400s; a story that has already started work keeps its own onePullRequest fixed rather than inheriting a new default. The playbooks-source PUT 403s if the caller is not an owner, and 400s if the project binds no repository or any playbook row still names it. The reflection PUT 403s if the caller is not an owner, 400s on a cadence that is zero or negative, and 400s naming the Playbooks page if no playbook row would run the reflection a non-null cadence files. POST .../reflect 403s if the caller is not an owner, and 400s naming the Playbooks page the same way if no playbook row covers it. The runner-tags PUT 403s if the caller is not an owner, and 400s on a tag that breaks its format rule or a list over the count limit.

Columns#

Method Path Who What it does
GET /statuses key Every column
POST /statuses key Creates one
PATCH /statuses/{id} key Name, sort order, color, isTerminal, isDeferred
DELETE /statuses/{id} key Deletes an unused column
PUT /statuses/{id}/express-skips person Ticks/unticks whether an express issue skips this column with no session
PUT /statuses/{id}/parent-pulls person Ticks/unticks whether a child is pulled through this column while its parent is in implementation
PUT /statuses/{id}/agent-files person Ticks/unticks the one column a program's issues are born in
PUT /statuses/{id}/implementation person Ticks/unticks the one column where code is written
PUT /statuses/{id}/protected person Ticks/unticks who may move work into this column
PUT /statuses/{id}/merges-pull-request person Ticks/unticks whether the loop merges the pull request once a card stands here, current and green
PUT /statuses/{id}/runner-tags person Replaces the whole list of runner tags a column requires

The board and its columns says what each flag means; this page says which route sets it.

StatusDto carries Id, Name, SortOrder, IsTerminal, IsDeferred, IsWip, Color, ExpressSkips, ParentPulls, AgentFiles, IsImplementation, IsProtected, MergesPullRequest. StatusCreateRequest and StatusPatchRequest share a shape - Name, optional SortOrder, IsTerminal, Color, IsDeferred. The six flag writes each take one required boolean: ExpressSkipsRequest(ExpressSkips), ParentPullsRequest(ParentPulls), AgentFilesRequest(AgentFiles), ImplementationRequest(IsImplementation), ProtectedRequest(On), MergesPullRequestRequest(On) - the last two take a field named On, not a field named after the flag.

Refusals worth naming. A PATCH 409s on a duplicate name, and 400s setting isTerminal: true while MergesPullRequest or IsProtected still holds - untick those first. agent-files and implementation 400 turning on over a deferred or terminal column, and ticking one column auto-clears every other - at most one of each ever holds. protected 400s turning on over a deferred or terminal column, and 400s turning off while MergesPullRequest still holds. merges-pull-request 400s turning on over a deferred or terminal column, or one not yet protected. DELETE 409s if the column still holds issues, a playbook names it (from or to), a project's auto-heal points at it, a verdict was recorded against it, or it is the board's last column.

WIP#

Method Path Who What it does
GET /wip key The section: flagged columns, and the two slices (story,bug then epic) with their limits
PUT /wip person Sets the limit, the epic limit, and/or the whole section's columns
PATCH /issues/{key}/wip person Sets an epic's own limit on how many of its stories run at once

The board and its columns covers what the section and its limits mean.

WipSectionDto is StatusIds and Slices, each a WipSliceDto(Types, Limit?). The section PUT body, WipSectionRequest(Limit?, StatusIds?, EpicLimit?), leaves a field alone when it is absent; "" or whitespace clears a limit, a positive whole number sets it; StatusIds, when sent, is the whole section, not a delta, so [] clears it. The per-issue PATCH body, IssueWipLimitRequest(Limit?), follows the same clear rule.

Refusals. The section PUT 400s a limit that is not blank or a positive whole number, and 400s naming a deferred or terminal column in StatusIds. The per-issue PATCH 400s if the issue is not an epic.

The board#

Method Path Who What it does
GET /board key Every column and every card in the house, in one request - the WIP meter alongside it, read from the same instant

The answer is a BoardDto: Statuses, Cards (each an IssueCardDto), an optional Wip, an optional DoneHorizonHours. There is no write here, and no refusal beyond the standard ones.

Issues#

Method Path What it does
GET /issues/{key} One issue, the full IssueDto
GET /issues Cards matching a filter - no parameters at all is the whole board
POST /issues Files one; lands leftmost/bottom unless a program's agent-files column exists
PATCH /issues/{key} Edits whichever fields the body names; null leaves a field alone
POST /issues/bulk One edit applied to many issues by key
DELETE /issues/{key} Deletes it; children are outdented, not deleted
POST /issues/{key}/move The drag: a column and the neighbours it landed between

All eight are a key's. GET /issues takes projectId, type, statusId, parentKey ("" means "no parent at all" - the one filter with no other way to write it), ancestorKey (anything below at any depth, not the issue itself) and text (a substring of the title, plus an exact key match), all optional and ANDed together. docs/hatch-planning.md has these filters in full, the bulk endpoint's own filters, and the /plan reads' arithmetic - none of that is re-derived here.

IssueCreateRequest is ProjectId, Type, Title, an optional Description, ParentKey, ReadyAt, DueAt, OnePr, Priority and PreApproval - OnePr only on a story, refused on anything else, inherit/on/off; Priority and PreApproval take the level names the two PUT routes below take, absent meaning normal and none, so an issue can be born at a level in the one save, with no second event. IssuePatchRequest is Title?, Description?, Type?, StatusId?, ParentKey?, ReadyAt?, DueAt?, PullRequestUrl?, ProjectId?, MoveDescendants?, WipOverride - "" clears ParentKey, ReadyAt, DueAt or PullRequestUrl (no issue key, date or URL is ever legitimately empty, so the sentinel is unambiguous); ProjectId has no clear sentinel, since an issue always belongs to some project; a null MoveDescendants reads as true. IssueBulkEditRequest shares those fields, minus Title, Description and PullRequestUrl, which only make sense one issue at a time, under Keys. Its answer, IssueBulkResultDto, is Changed, Unchanged, Failures (each {key, reason}) and Rekeyed (each {from, to}, for every issue a project move actually carried, named or not). IssueMoveRequest is StatusId, AfterKey?, BeforeKey?, FromStatusId?, WipOverride - the client names neighbours, never a rank, and FromStatusId is a precondition: a card moved since is refused rather than relocated.

Refusals worth naming. Create and patch 400 on an invalid title, type, date or pull-request-URL shape, and on an illegal parent - wrong project, a cycle, or a type pairing the issue hierarchy refuses. A move into a protected column 403s unless the caller is a live owner or approver, never a program, and into a merges-pull-request column 400s if the issue carries no pull request URL. A column change that would overflow the WIP section 409s with a {error, load, limit} body unless wipOverride: true - and wipOverride, and a cross-project ProjectId on a patch, are person-only even on this key-open controller: a program sending either is 403'd before the issue is even loaded. Create has person-only fields of its own the same way: a program that names priority or preApproval, even as normal or none, is 403'd before the project is looked up or a number is minted. A person who names preapproved must be an owner or approver of the project, and one who names express must be allowed to move work into the board's send-back column - the two questions PUT /issues/{key}/pre-approval asks - and a refusal files nothing. An unknown level name 400s, naming the levels. A project move 409s if any issue in the moving set - the root, or the whole subtree unless moveDescendants: false - holds a live claim. The move route additionally 409s if FromStatusId does not match where the card actually is, and 409s, as a plain string rather than the WIP body, if the card's live claim belongs to a check playbook - a check session judges, it does not move the ticket. Both create and a project-moving patch or bulk retry up to five times on a number-minting race before answering 409 "could not mint an issue number - try again".

A ticket's own fields#

Field Method Path Who What it does
assignee PUT /issues/{key}/assignee person Sets/clears who it belongs to
priority PUT /issues/{key}/priority person One of six named levels
expedite PUT /issues/{key}/expedite person Legacy two-level alias for priority (expedited/normal)
pre-approval PUT /issues/{key}/pre-approval person One of three named levels
express PUT /issues/{key}/express person Legacy two-level alias for pre-approval (express/none)
one-pr PUT /issues/{key}/one-pr key on/off/inherit, stories only
runner-tags PUT /issues/{key}/runner-tags key Replaces the ticket's own runner tags whole
release-note PUT /issues/{key}/release-note key The changelog's sentence for this issue
stall PUT /issues/{key}/stall open to anything the wall admits A runner's own mark that it left the ticket where it found it, and why
hold PUT /issues/{key}/hold person Parks/resumes a ticket; clears any stall mark
playbook PATCH /issues/{key}/playbook person Per-ticket model/effort override, beating every playbook
landing PUT /issues/{key}/landing key What a repository holds for this issue's branch, per remote

Several of these are a person's alone; checked one at a time rather than assumed uniform. one-pr, runner-tags and landing are key-open despite sitting beside five person-only fields: one-pr is the story's own say over its project's default, which gives a key nothing it could not already do by filing the story that way; runner-tags is a statement about which runner may pick the ticket up, not a power over the board, for the same reason; landing is a fact a session or a poll reports about a repository and a sha, not a power over the board. stall carries no [RequireRole] at all, on the method or the class - it is reachable by anything the wall admits, person, key or a keyless runner that named itself, which is deliberate: a dispatcher is exactly who leaves this mark. release-note is key-open but claim-gated: a person may write it any time, a program caller only while holding the issue's live claim, sent as ?heldToken=, and is refused with a 409 otherwise.

AssigneeRequest is Kind? and Id? - both or neither, and neither clears. PriorityRequest(Priority) takes one of paused, economy, low, normal, expedited, emergency; ExpediteRequest(Expedited) is the two-level alias. PreApprovalRequest(Level) takes one of none, express, preapproved; ExpressRequest(Express) is the two-level alias. OnePrRequest(Value) takes on, off or inherit. IssueRunnerTagsWriteRequest(Tags) is the whole list, replaced; trimmed, lower-cased and deduplicated, at most 8 tags of at most 32 characters, none with a space or comma. ReleaseNoteRequest is an optional Note plus the query parameter heldToken (a guid, program callers only) - null or blank clears it. StallRequest(Why) is one line, truncated. HoldRequest(Held) is a boolean. IssuePlaybookRequest(Model?, Effort?) leaves a field alone when null, and hands it back to the playbook on "". LandingWriteRequest is Remote, Sha, an optional Trunk, ContainedBy and Runner.

Refusals worth naming. assignee 400s on a kind with no id, or an unknown actor. priority/expedite 400 an unparseable level name - the alias is just a two-level write of the same field and the same trail. one-pr 400s on anything but a story, and 409s once any descendant has started - standing in the implementation column or to its right, since a task begun on one branch cannot be moved onto the other. runner-tags 400s a blank tag, one with a space or comma inside, one over 32 characters, or a list over 8; 404 for no such key; re-sending the list it already holds writes nothing. pre-approval and express gate by what is being set: raising to or lowering from preapproved needs an owner or approver, 403 otherwise, since that is the approve press given in advance; every other transition is gated like an ordinary move into the board's send-back column, where one exists. playbook 400s an unknown model or effort - the same validation a playbook file is held to. landing 400s a malformed remote, sha or trunk, and the first write for a repository 400s if it omits trunk. Every field here follows the house no-op rule: setting the value already held writes nothing and moves no UpdatedAt.

The thread#

Method Path What it does
GET /issues/{key}/comments The thread, oldest first
POST /issues/{key}/comments A note, a question, an answer, or a message
GET /issues/{key}/events The audit trail, newest first
GET /questions Every question in the house, oldest first
GET /issues/{key}/questions The same, for one issue
POST /issues/{key}/messages/deliver Marks messages read; answers with the ones this call marked

All six are a key's. Both question routes take ?open= (default true): open is unanswered and not terminal, open=false is every question, answered and obviated ones included.

CommentDto is Id, Author, Body, Kind ("", question, answer or message), an optional AnswersId, an optional Options, CreatedAt, an optional DeliveredAt, an optional DeliveredTo. CommentCreateRequest is Body, an optional Kind (omitted means an ordinary note), an optional AnswersId, an optional Options. QuestionOptionDto is Label, an optional Detail, Recommended - at most eight options per question, at most one recommended, labels unique case-insensitively. QuestionDto is Id, IssueKey, IssueTitle, Body, AskedBy, AskedAt, an optional Options, Answers (oldest first - an empty list is what "open" means, no second flag), Obviated. IssueEventDto is Id, Actor, Kind, an optional Payload, At. MessageDeliverRequest is an optional Ids - exact ids, or null for every unread one; ids that are not unread messages on the issue are ignored rather than refused.

Every write here leaves an event carrying the caller's name as the actor, so the trail says who did what without anybody being asked to record it.

Refusals worth naming. POST comments 400s an empty or over-length body, an unknown kind, an answersId on anything but an answer, options on anything but a question, and a batch of refusals on bad options: one option alone, more than eight, duplicate labels, an over-length label or detail, more than one recommended. An answer's answersId must name an actual open question on the same issue, or it is 400'd. Nothing here checks whether the caller may answer its own question - that gap is deliberate, since what holds the loop shut is that an open question blocks dispatch entirely, not a check on who answered.

Dependencies#

Method Path What it does
POST /issues/{key}/dependencies Makes this issue wait on another
DELETE /issues/{key}/dependencies/{dependsOnKey} Frees it from one

Both a key's. There is no GET: both routes answer the whole IssueDto, so a page repaints from one response. The POST body is IssueDependencyRequest(DependsOnKey).

Refusals. 400s self-dependency, a blocker above or below the issue in the parent tree (it could never be satisfied), and a blocker that already - directly or through a chain - waits on this issue, the cycle the new edge would close. Adding an edge that is already there, or deleting one that is not, writes nothing and answers the issue unchanged - no error either way.

Search, the plan and assignees#

Method Path What it does
GET /search Full-text search across title, description, comments and work log, paged
GET /plan Every epic's rollup, the Plan page's one request
GET /plan/{key} One issue's rollup and its children's
GET /assignees Everybody an issue could belong to, plus who the caller is

All four a key's. /search takes q, projectId, type, statusId, createdFrom/createdTo, closedFrom/closedTo, limit (1-200, default 50) and offset; with q, best match first and closed-ahead-of-open among equals, without it newest first (closed, else created). /plan takes an optional projectId. /assignees takes nothing, and answers an AssigneeDirectoryDto(Me?, Assignees). docs/hatch-planning.md has the plan's arithmetic in full.

Refusals. /search 400s an invalid type, a limit outside 1-200, a negative offset, or an unparseable date bound. /plan/{key} 404s an unknown key; /plan with an unknown projectId answers an empty plan rather than a 404, since an empty or just-deleted project is a legal thing to look at.

Images and import#

Method Path Who What it does
POST /images person Uploads one (raw body, detected content-type)
GET /images/{id} key Fetches it
POST /import/preview key What uploaded .md files would become; reads/writes nothing
POST /import/preview-text key The same, for a pasted title and body
POST /import key Files the approved tree: epic per doc, story per phase, task per checkbox

ImageDto is just Id. A preview's answer is the parsed tree, one doc per file - the shape is internal to the importer and not worth repeating here. preview-text's body is PastedPlan(Title, Body). The import write's body is ImportRequest(ProjectId, Docs), and its answer is ImportResultDto(Epics, IssueCount), each epic an ImportedEpicDto(Filename, Key, Title, StoryCount, TaskCount).

Refusals. Image upload 403s a program caller outright - images are pasted by a person, not uploaded by an agent - and 400s an undetectable content type. preview 400s a non-.md filename, a file over 1 MB, or more than 25 files. preview-text 400s an empty title or body, a title over the issue title cap, or a body over 1 MB. import 400s an empty or over-25 Docs list, 404s an unknown ProjectId, 409s a board with no columns, and - like create and a project move - retries a numbering race up to five times before 409ing.

The loop's own routes#

Everything above is a resource the board itself owns. What follows is what a runner calls to do its own work, and what the operator reads to watch it - the second half of this page, after the board's own.

Work: what a runner asks the board for#

Method Path What it does
GET /work/next The next clear issue an unattended pass would pick up
GET /work/queue What a whole pass would do, blocked issues included, each with the sentence saying why
GET /work/review Every issue in review or merges this caller holds a checkout of, and what the board holds about its branch
GET /work/{key} The same dispatch answer for one named issue
POST /work/{key}/hop Carries an express issue, or a child its parent pulls, across its column with no session
POST /work/{key}/verdict Applies a check session's recorded verdict - passes it on, sends it back, or holds it for a person on a second fail in a row
POST /work/{key}/merged Advances a reviewed issue whose pull request the forge says merged
POST /work/{key}/pushback Carries a merge-column issue back to review once its branch conflicts or its build fails
POST /work/{key}/landed Advances a trunk-landing issue once its commit is confirmed landed and the trunk is green
POST /work/{key}/closed Records that the pull request recorded on an issue closed without merging

All ten are a key's. This is the group a reader comes to the page for: hatch work calls GET /work/{key} for a named ticket and GET /work/next for an unattended one; hatch go-to-work calls GET /work/queue once to log what it sees, then GET /work/next in a loop - see one increment and the loop for what the increment does with the answer, rather than retold here. /work/next and /work/{key} answer a WorkDto: the issue, the move it would make, and the playbook row and the prompt the increment is to run where nothing blocks it, or the sentence saying what does - see what a session is told for what the prompt itself holds.

next, queue and {key} take remote, standing, clones to declare a caller's checkouts (any one opts into the repository fold), plus ancestorKey to narrow to one corner of the board and mine to narrow to the caller's own tickets; {key} and next also take heldToken, a claim the caller already holds, so re-reading a dispatch for a ticket it is already working is not folded as a conflict. queue instead takes asRunner, which answers off that named runner's own row - its repositories, mine and ancestorKey - rather than the query string, and may not be combined with any of them. The four write routes (merged, pushback, closed) take PullRequestMergedRequest/PullRequestPushbackRequest/PullRequestClosedRequest(Url, Runner), comparing Url against what is recorded on the issue; verdict takes only heldToken, naming the claim the verdict was recorded under; landed takes no body at all and trusts nothing from its caller, re-reading the landing and the trunk build fresh.

Refusals worth naming. hop 409s when the move it would make is blocked, and 409s again naming the sentence "is not a hop" when nothing about the issue is a hop at all. merged/pushback/closed 409 when no pull request is recorded, or when the given Url is not the one recorded; pushback separately 409s with the judge's own sentence when the branch is not actually a pushback. merged/pushback/landed 409 when the issue holds a live claim - a session is running on it, so nothing here moves the row out from under it. landed 409s off the review column and 409s again naming whether the project lands by pull request at all or already carries one of its own.

Claims#

Method Path What it does
POST /issues/{key}/claim Takes the lease, or says who already has it
POST /issues/{key}/claim/heartbeat Refreshes the lease, and answers whether the board has preempted it for an emergency
DELETE /issues/{key}/claim Lets go of it

All three are a key's, with one narrowing below. A claim is a lease with a clock, not a dispatch of its own - WorkController is what decides whether to send anything at an issue, and a claim that second-guessed that would be the same rule in two places. The lease's length is the Hatch__ClaimTtlSeconds setting (default 300 seconds); taking a claim carries it back as TtlSeconds alongside StallLapseSeconds, so a caller never has to know the setting's name to honor it. A heartbeat renews the lease and may replace the playbook it is running under; it writes no event on an ordinary beat, only on a preemption, which is the board's own decision to make room for an emergency ticket and is the one case a heartbeat answers a ClaimPreemptedDto instead of 204.

A live claim refuses a second POST, including the same runner's own - re-claiming is not a thing a runner does, it heartbeats - and a claim on an ancestor or descendant issue refuses it too: one runner per line of the tree. The DELETE carries an optional token (the one the claim was taken with) and an optional outcome - one of dropped, worked, preempted - read only alongside a token. Releasing a claim that holds nothing, or releasing twice, is a 204 that writes nothing either way.

The one narrowing. A tokenless DELETE is the operator's own clobber - clearing another runner's claim by hand - and it refuses a program caller outright even though the route itself is key-open: an agent that could take a ticket off another agent mid-increment is the exact failure the claim exists to prevent, reintroduced through its own back door. A runner lets go of its own lease by presenting the token it was given, which every honest runner has. This is deliberately not on the person-only list in Who may call what below - the attribute itself does not narrow the route, the action body does.

Checks, pull requests and landing#

Method Path What it does
POST /issues/{key}/verdicts Records a check session's pass/fail about the issue, under its live claim
PUT /issues/{key}/merge-check Replaces the stored verdict for the issue's branch against one repository's trunk
PUT /issues/{key}/build-check Replaces the stored verdict for the build on the tip of the issue's branch in one repository
PUT /issues/{key}/pull-request-state Replaces the stored mergeability, review decision and head sha for the issue's recorded pull request
PUT /issues/{key}/brought-current Marks the issue as the one the train last brought current, and at which trunk sha
GET /trunk-builds Every repository trunk's stored build verdict
PUT /trunk-builds Replaces the verdict for one repository's trunk
POST /trunk-builds/{id}/bug Files the bug the attention panel's File a bug button asks for, from a failing trunk

All seven are a key's. None of this group has a GET beside the issue routes - a verdict, a merge check and a build check all ride an IssueDto/ ReviewCheckDto, which is where every client needs them, rather than a second route to keep in step. verdicts is the one route here that is a session's and not a poll's: it names no remote, is matched to the issue's live claim token, and only takes effect once /work/{key}/verdict is called with that same token - a check session records what it found, and the loop decides what to do about it. The other six are what a runner's poll writes with no session at all, the same background process letting the loop merge for you describes driving the merge train from: a merge check and a build check are facts about two refs on origin any runner computes the same way, a pull-request state is what the forge itself already says, a brought-current mark is the train saying which row it just updated, and a trunk build is nobody's issue, so its one route answers to no claim and no column at all. /trunk-builds/{id}/bug is the one write in this group a person can also take, from the attention panel's own button; a key filing one gets none of the three optional fields (express, priority, the column) a person may choose - they come off the project's own auto-heal settings, and only where a project has turned auto-filing on.

Refusals worth naming. verdicts 400s a verdict that is not pass/ fail, and refuses where the issue holds no live check-playbook claim. merge-check/build-check/pull-request-state/trunk-builds 400 an unparseable remote, verdict, merge state or review decision, and (for a failed or pending build or trunk verdict) a missing list of failing checks. pull-request-state 409s when no pull request is recorded on the issue, or when the given url is not the one recorded. brought-current 409s where the issue's column does not merge pull requests. A verdict, build or merge check that repeats what is already stored refreshes the shas, the runner and the time, and writes no event - a poll that looks every interval leaves one entry, not a hundred.

Playbooks#

Method Path Who What it does
GET /playbooks key Every row, in board order
POST /playbooks person Creates one
PATCH /playbooks/{id} person Edits one
DELETE /playbooks/{id} person Deletes one
GET /playbook-files key Every stored verdict of what a repository's trunk actually carries at .hatch/playbooks.yaml
PUT /playbook-files key Replaces the stored row for one repository's trunk, from a runner's poll of the file

A key may read a playbook and may not write one, and the refusal is deliberate. An agent that could widen its own prompt, or raise its own model or effort, has no fixed point to settle at, and the failure that follows is unbounded spend rather than a wrong answer - the same shape of loop the claim's tokenless DELETE above and a runner's own --max-spend are cut to prevent. Reading stays open because an agent has to be told what it was dispatched with.

/playbook-files is not that same edge, despite sitting beside it: it is not the matrix above, it is a cache of what a runner's poll actually found on disk at a repository-sourced project's trunk - a fact a key reports about a file, not an edit to Hatch's own instructions - so both its GET and its PUT stay key-open. Check each verb against its own attribute rather than assume the group is uniform.

PlaybookDto carries the transition (FromStatusId/FromStatusName, ToStatusId/ToStatusName), Types, Shape, the effective Prompt beside the row's own DefaultPrompt, Model, Effort, an optional Budget (millions of tokens) and BriefLimit (characters), DeniedTools, IsCheck, and Source - "board" or "project", by whether ProjectId names one. The create and patch bodies share that shape with the three-state convention every optional field in Hatch takes: absent leaves a field alone (patch) or takes its default (create), "" clears it, a value sets it.

Refusals worth naming. Create and patch 400 a column that does not exist, a transition that goes nowhere - unless it is the review column naming itself, which is the one review playbook a board may have - a duplicate of a row already filed for that transition, those types, that shape and that project, an unknown model or effort, and a project that reads its playbooks from its own repository rather than from rows here at all. playbook-files' PUT 400s an empty remote, trunk, sha or runner, or content over the stored cap, and writes no event - there is no issue to write one on, and no question, since a bad playbooks file is this row's own state rather than a stall.

Runners and settings#

Method Path Who What it does
GET /runners key Every runner heard from lately, most recent first
GET /runners/names key The name of every runner heard from lately, most recent first, and nothing else
POST /runners/{name} key A runner's own heartbeat: what it is, and what the board would like it to do next
PATCH /runners/{name} person Sets its state, under, max-runs, max-spend and until
GET /runner person What platforms this image can hand out, and the commit they and it were built from
GET /runner/download/{rid} person One platform's hatch binary
GET /settings person Hatch's own settings: the Claude subscription token, the agents' API keys, the local person's name
PUT /settings person Edits them
GET /settings/credentials/{agent} key One agent's credential, unprotected, for the container runner to start it with
GET /settings/claude-token key The same, kept for an older container; credentials/claude is its successor
GET /me key Who is at the browser, for the nav strip

/runners/{name} is the one call a running loop makes about itself, and the whole round trip: it reports what it is - its repositories, whether it clones, mine, standing, where it runs, which agent spawned it - and reads back a RunnerInstructionDto saying whether to keep going, pause or stop, and the four bounds (Under, MaxRuns, MaxSpend, UntilAt) it is running within. Those four bounds are reported on a runner's first heartbeat and set only by the person's own PATCH - a restart sending its original --max-runs again never undoes an operator's midnight edit, and an agent that could raise them through its own heartbeat could raise its own budget, which is the failure the bounds exist to prevent. There is no DELETE: a row ages out of GET /runners on its own once nothing has heard from it in a while.

/runner and /runner/download/{rid} carry no scope at all, unlike every other Hatch-scoped read on this page - the audience is a person at a browser setting up a machine, and nobody's dispatcher needs to download the program it is already running as.

/settings is Hatch's own configuration, carrying nothing an agent reads: it is person-only on both verbs by design, beside /runners/{name} and /playbooks' writes above - an API key that could write here could set the name every event in the house is signed with, or swap the credential the account's headroom is read through. credentials/{agent} and claude-token are the one deliberate exception beside it: the answer is a live credential, and it is never logged - the one place in Hatch a secret is handed back out of the API on purpose, for the one caller that has nowhere else to read it from, the container entrypoint's own hatch runner-credential - see the container runner and cli-reference. Both refuse outright wherever the wall is up - a credential that crosses a network Hatch does not own is a different question from one handed to a container on the same laptop.

/me answers a MeDto(Kind, Name, Role, Configured, CanSignOut, CanSignIn)

  • kind: person with the wall up, kind: local with it off, and 204 for a key or a keyless runner, which is not somebody at a keyboard.

Refusals worth naming. PATCH /runners/{name} 404s a name no runner has ever used - it edits, it never creates. With the wall up, it also 403s a caller who is neither the runner's own owner nor an admin, naming the owner and the runner in the sentence. POST /runners/{name} 409s when two live runners would otherwise share one name. credentials/{agent} 404s an agent it does not know and 403s wherever the wall is up; a credential that is unset or blank answers 204 rather than either refusal.

What a night left behind#

Method Path Who What it does
POST /issues/{key}/work-log key Records what one session cost on this issue
GET /issues/{key}/work-log key This issue's own sessions, and what its whole subtree has cost
GET /work-log/history key Spend over time, in equal buckets
GET /work-log/sessions key The sessions in a range, ranked, and what the range cost in total
GET /work-log/by-playbook key The sessions in a range, folded by the playbook, transition, issue type, model and effort they ran under
GET /overnight key What landed in the newest night
POST /overnight/seen person Marks the night read, for the caller
GET /activity key The event trail across every issue, newest first, a page at a time
GET /changelog key What shipped, a window of days at a time

Watching the loop is what draws these on screen; this page is their shapes. work-log's POST is the one write on this whole page that is a key's and nothing else's, the opposite cut from everything above it: it refuses a person - a work log entry is written by the dispatcher, with an API key - there is no control anywhere in the browser for writing one and none is meant to exist. Its GET, on issues/{key}/work-log, answers a WorkLogDto(Key, Totals, Own, Entries) where Totals covers the whole subtree and Own/Entries cover only this issue - an epic showing a handful of its own sessions beside a much larger Totals is the feature, not a bug. /work-log/history buckets by hour or day, chosen from the range's length unless named, and answers the bucket size it actually used; /work-log/sessions ranks by tokens (the default), cost, ended, requests or peakContext, each descending. Both take an optional ancestorKey, read inclusive of the issue itself - deliberately unlike the same parameter on GET /issues - so the graph, the table and the issue page's own meter can never describe different populations. /work-log/by-playbook folds the same range by the playbook, transition, issue type, model and effort each session ran under, and takes projectId in place of ancestorKey; it answers every group, heaviest first by total tokens, with no sort or limit to choose, and an unknown projectId answers no rows rather than a refusal. Sessions from an older runner, which did not record those, fold into one row of nulls, and a status name is the board's current one - null where the column has since gone.

/overnight answers an OvernightDto(From, To, Count, Rows, Unseen), grouping and gap-splitting the newest night's terminal landings; seen is a person's own mark of having looked, and refuses a program for the same reason work-log's write refuses a person, in reverse - a keyless runner that named itself is refused here too, since it is an agent whether or not it carries a credential. /activity pages the same event trail /issues/{key}/events reads for one issue, across every issue instead, ordered the same way so one event never sorts differently between the two reads; it takes limit (1 to 500, default 100) and offset, and answers hasMore rather than a count. /changelog takes before, projectId and days (default 14), answering the newest window of days that shipped something, with every closed month's own count beside it for the jump control - this is the same route the Changelog entry at the top of the app's nav draws from, not a page of its own.

Refusals worth naming. work-log's POST 400s a missing session id. history 400s an offset outside +-1440 minutes, an unrecognised bucket name, or a request whose buckets would exceed 1000. sessions 400s an unrecognised sort or a limit outside 1-500. activity 400s a limit outside 1-500 or a negative offset. changelog 400s a window under one day or a project that does not exist. history/sessions/by-playbook/changelog all 400 an unparseable range bound, in the order to, then from, then the inversion.

Reports and the battery#

Method Path What it does
GET /reports/timeline The top-level epics active in a range, and the work beneath each as stretches across the board's columns
GET /reports/cumulative-flow How many issues sat in each column over time, as a grid; a terminal column baselines at zero at the window start and counts only what closed inside it
GET /reports/flow How work moved through the board in a range: per column what entered, left and stands, and how long it dwelled, plus the window's drops, hops, send-backs, questions and sessions - counts and durations only
GET /utilization The caller's own Claude headroom - the battery in the nav strip
GET /attention What the loop is waiting on a person for, in one request

All five a key's. timeline takes from, to (default the ninety days ending now), projectId and createdBy; cumulative-flow takes only from/to, the same ninety-day default; flow takes from/to the same way, plus projectId and type, and 400s an unknown type or projectId. None of the three queries is itself bounded by the range - the fold needs an issue's first move even where it falls outside it - but the ranges' bounds are. utilization answers a UtilizationReading(State, ReadAt, Limits) off whatever this caller's own runners last reported on their own heartbeat, never the account's endpoint directly, and 204s for a caller with no runner ever reporting one, which is the ordinary state for a fresh install rather than an error.

attention is the one request the nav strip's badge and panel both draw from, since two polls a poll interval apart would show a lit control beside an empty list. It answers an AttentionDto: open questions waiting on somebody, pull requests in review split into plain reviews, conflicts, and failing builds, how many sit in review with no pull request at all, the merge column's own held-back and still-progressing rows, trunk-landing rows the judge holds, failing trunk builds, exhausted runners, and the deadlocked rows the dispatcher's own scan flags - an issue waiting, directly or through a chain, on something itself waiting on it.

Refusals. None beyond the ordinary ones - every route in this group is a read with no body to reject.

Who may call what#

One scope, hatch, and it reaches everything under /api/hatch less the routes that take a person only. A key that calls one of those gets a 403, and that is the gate working as designed, not a misconfiguration - People, roles and keys says the same thing in prose, grouped by what the route does; this list is the route-by-route version it already points to, and should not repeat or contradict it.

Recounted directly from the [RequireRole] attributes - the method's where it carries one, the class's otherwise (AssigneeController is the one file on this page with both) - there are 36 person-only paths on this page, across both halves together:

PUT    /projects/{id}/repositories
PUT    /projects/{id}/logo
DELETE /projects/{id}/logo
PUT    /projects/{id}/members/{personId}
DELETE /projects/{id}/members/{personId}
POST   /projects/{id}/claim
PUT    /projects/{id}/landing
PUT    /projects/{id}/playbooks-source
PUT    /projects/{id}/reflection
POST   /projects/{id}/reflect
PUT    /statuses/{id}/express-skips
PUT    /statuses/{id}/parent-pulls
PUT    /statuses/{id}/agent-files
PUT    /statuses/{id}/implementation
PUT    /statuses/{id}/protected
PUT    /statuses/{id}/merges-pull-request
PUT    /statuses/{id}/runner-tags
PUT    /wip
PATCH  /issues/{key}/wip
PUT    /issues/{key}/assignee
PUT    /issues/{key}/priority
PUT    /issues/{key}/expedite
PUT    /issues/{key}/pre-approval
PUT    /issues/{key}/express
PUT    /issues/{key}/hold
PATCH  /issues/{key}/playbook
POST   /images
PATCH  /runners/{name}
GET    /runner
GET    /runner/download/{rid}
GET    /settings
PUT    /settings
POST   /overnight/seen
POST   /playbooks
PATCH  /playbooks/{id}
DELETE /playbooks/{id}

Twenty-six belong to a project, a column, the WIP section, a ticket's own fields, or image upload; the other nine are a runner's own record, the /runner pair, Hatch's settings on both verbs, marking the overnight seen, and the three playbook writes.

Two writes that look like the same shape are deliberately not on this list, because the attribute itself does not narrow them - the narrowing is in the action body, against whether the caller is a program at all, the same guard work-log's own refusal (above, inverted) uses: the tokenless DELETE /issues/{key}/claim (Claims, above) and wipOverride/a cross-project ProjectId on PATCH /issues/{key} (the Issues table, earlier on this page). A reader scanning only [RequireRole] attributes would miss both; this page does not, by calling each out in its own group's prose instead of padding this list with routes that are not, by the attribute, person-only at all.

Refusals#

  • 400 - a field or value the body or the query sent is wrong, named plainly: "a verdict is one of pass, fail", "an offset is not negative - not -4".
  • 401 - one of two different things behind one code: a key that was sent and refused (revoked, or malformed), or no key at all against a Hatch whose wall is up and wants one. Neither ever reaches an action - the check runs as an authorization filter, before model binding.
  • 403 - either the route is not one this key may take at all (the person-only list above), or the caller is not a live owner or approver of the project the move or the edit concerns - a key or a runner never counts as either, however it is scoped. The error names the reason in both cases, since the caller is already authenticated and the sentence a page shows for "this device is not linked to anyone" is not the sentence it shows for "you are not an administrator".
  • 404 - no such issue, no such row, or no such route. An issue key nobody filed is a 404, never an empty issue - none of the shapes on this page answer "nothing here" by handing back zeroed fields.
  • 409 - the state itself refuses the write: a move into a terminal or protected column, a live claim already standing, a project that still holds issues or still has a playbook naming it, a last owner about to be removed, two runners about to share one name.

cli-reference turns every one of these into one of five named sentences rather than a raw status code, for the loop and for a terminal both.