GraphQL API (lev serve)
lev serve answers GraphQL at POST /graphql, beside the REST routes. One request
names exactly the fields it wants, at any depth. A field you do not ask for is never read from disk.
This is not a layer over the REST routes. Both surfaces call the same code inside the server, so neither can drift from the other, and a GraphQL request costs the same single hop a REST request costs.
Use it when one screen needs several things at once. A fleet view that lists runs with their spend and whatever is waiting on a person is one request here. Over REST it is a listing plus one request per waiting run, with the join done in your client.
REST is not going anywhere. For "cancel this run" or "read this file", a single REST route is still the simplest thing that works.
New to GraphQL?
If you know REST, the mapping is short.
| REST | GraphQL |
|---|---|
| Many URLs | One URL, POST /graphql |
| The OpenAPI spec | The schema, published as leviath.graphql |
GET |
A query operation |
POST, PUT, DELETE |
A mutation operation |
?fields=a,b on one route |
The selection set, on every field |
?cursor= and next_cursor |
after: and the connection's cursor |
| A status code per failure | 200 with an errors array, each entry carrying a code |
Two habits to bring with you. Ask for the fields you render, because everything else is work the
server skips. Read errors on every response, because a 200 can still carry a failure for one
field while the rest of the answer is fine.
Auth
The same bearer token as the REST routes, from --token or LEVIATH_API_TOKEN.
curl -s localhost:3000/graphql \
-H "Authorization: Bearer $LEVIATH_API_TOKEN" \
-H 'content-type: application/json' \
-d '{"query":"{ runs(first: 5) { results { id title status } } }"}'A missing or wrong token is a plain-text 401, before any query runs. The in-flight cap and the
request deadline apply here too, so this endpoint can answer 503 or 408 like every other one.
See limits.
What a type's name tells you
Every type in this schema carries a suffix, and the suffix says what the type is for. Read it and you know whether a name belongs in a selection set, in a filter or in a mutation argument, without opening the schema.
| Suffix | Kind | Reach for it when |
|---|---|---|
XOutput |
object | You are reading. RunOutput, StageOutput |
XInput |
input | You are filtering. It mirrors XOutput field for field |
XListInput |
input | The field is a list. Holds some, every and none |
XFilter |
input | The field is a scalar or an enum. StringFilter, RunStatusFilter |
XOrder |
input | You are sorting. One key and a direction |
XConnection |
object | You asked for a page. results, cursor, total |
XRef |
input | You are pointing at something that already exists. BlueprintRef |
VerbNounRequest |
input | It is the single argument of the mutation verbNoun |
VerbNounResult |
object | It is what verbNoun answers with, carrying the entity |
XWrite |
input | It is a write shape nested inside a request. McpServerWrite |
XOptions |
input | It is a read-side argument that is not a filter. RunSearchOptions |
XEvent |
object | It is a subscription frame, named in the past tense |
One query uses four of them at once. blueprints takes a BlueprintInput filter and a
BlueprintOrder sort key, and answers with a BlueprintConnection of BlueprintOutput:
{
blueprints(
filter: { regions: { some: { kind: { eq: COMPACTING } } } }
orderBy: [{ field: NAME, direction: ASC }]
first: 10
) {
results { name version description }
cursor
total
}
}Enums, unions and interfaces carry no suffix. RunStatus, MimeTokenRule and Node are
vocabulary and abstraction rather than a place in the grammar.
One shape for every listing
Every collection in this schema takes the same four arguments and answers with the same three fields.
query FirstPage {
runs(first: 25) {
results { id title status startedAt }
cursor
total
}
}results holds at most first items. cursor names where you got to, and it is null when that
page was the last one. Pass it back as after for the next page:
query NextPage($after: Cursor) {
runs(first: 25, after: $after) {
results { id title status }
cursor
}
}There are no edges, no pageInfo and no per-item cursor. A connection may add a field of its own
beside the three, and two do: RunConnection.highlights carries search snippets, and
ToolConnection.skipped carries the scripts a walk found and could not offer.
A cursor belongs to the filter, the sort and the search it was minted under. Send it back with a different predicate and the server refuses it rather than resuming somewhere that means nothing. Change the filter and start again from the first page.
total is resolved only when you select it. For a filter answered from memory it is free. For one
that has to open files it walks the whole store, so ask for it on the first page and carry the
number, rather than asking again on every page.
Page sizes are capped per listing, and a first over the cap is refused rather than quietly cut
down. A client builds its query in code, and silently getting 200 of the 500 rows it asked for
shows up much later as missing data.
first cap |
Listings |
|---|---|
500 |
models, providers, tools |
200 |
runs, blueprints, scripts, mcpServers, mimeRows, yoloProfiles, updateJobs, openInteractions, and every listing on a run except the two below |
1000 |
files on a run, where a row is a name and a size |
100 |
contextHistory on a run, where a point carries a whole context window |
Separately, runs(filter: { id: { in: [...] } }) names at most 200 runs at once. That is not a page
size: it is how many records the filter may ask for by name, and going over it is refused.
Filters
A filter mirrors the thing it selects. RunInput has a field wherever RunOutput has one, under
the same name, wrapped in whatever compares that field's type. Set the fields that have to hold,
and one filter object is an and of its own fields.
The scalar comparators
One set, shared by every filter input in the schema.
| Filter | Operators |
|---|---|
IDFilter |
eq ne in notIn isNull |
StringFilter |
eq ne in notIn contains startsWith endsWith isNull |
IntFilter, BigIntFilter, FloatFilter, DecimalFilter, TimestampFilter |
eq ne in notIn lt lte gt gte isNull |
BooleanFilter |
eq ne isNull |
JSONFilter |
eq ne isNull, on the whole value |
StringListFilter |
has hasEvery hasSome isEmpty isNull |
One per enum, such as RunStatusFilter |
eq ne in notIn isNull |
{
runs(filter: {
startedAt: { gte: 1758326400, lt: 1758412800 }
cost: { costUsd: { gte: "1.00" } }
title: { contains: "parser" }
status: { in: [COMPLETE, COMPLETE_INTERACTIVE] }
}) {
results { id title status cost { costUsd costIsExact } }
total
}
}The exact string comparisons are case-sensitive. contains, startsWith and endsWith ignore
ASCII case, as the run search does. A Decimal bound travels as a string, because a cost figure a
JSON parser re-rounds is no longer the figure you sent. A Timestamp is unix epoch seconds, as a
number.
Absence, and the combinators
A field with no value satisfies no comparison. A run with no title matches no title filter
whichever operator you asked for, so "has no title" is its own question, and isNull is how you
ask it. Every filter in the schema carries it.
and, or and not compose filters of the same type at any depth. and is a list every member
of which has to match, or a list at least one member of which has to, and not one filter that
must not. An empty or list matches nothing, which is what an alternation with no alternatives
selects.
{
topLevel: runs(filter: { parentId: { isNull: true } }, first: 25) {
results { id title }
}
troubled: runs(filter: {
status: { in: [ERROR, CANCELLED] }
or: [{ blueprintName: { startsWith: "coder" } }, { yoloProfileName: { eq: "careful" } }]
not: { title: { contains: "scratch" } }
}, first: 25) {
results { id title status error }
}
}Asking about a list
Where the thing you are filtering holds a list, the mirror is an XListInput with three
quantifiers. some keeps it when at least one member matches, every when they all do, and none
when no member does.
{
usedAnthropic: runs(filter: { stageModels: { some: { provider: { eq: "anthropic" } } } }) {
results { id stageModels { provider model } }
}
allStagesDone: runs(filter: { stages: { every: { status: { eq: COMPLETE } } } }) {
results { id title }
}
noImages: runs(filter: { blobs: { none: { mimeType: { startsWith: "image/" } } } }) {
results { id }
}
}every holds over an empty list, which is what "every member matches" means when there are no
members. Reach for none when you mean "and there is at least nothing of this kind".
Asking about a union
A union's mirror carries one field per variant. Setting one asks about that variant, and a value of any other variant never matches it.
{
mimeRows(filter: { tokens: { perPixel: { pixelsPerToken: { gt: 700 } } } }, first: 25) {
results {
mimeType
tokens {
__typename
... on PerPixelOutput { pixelsPerToken max }
... on FixedOutput { tokens }
}
}
}
}Through a relation
Where a field of the output is another object, the filter field is that object's own filter. So a predicate follows the same path a selection set does, and you never have to flatten a question into a column name.
{
byRegionKind: blueprints(filter: { regions: { some: { kind: { eq: CHECKLIST } } } }) {
results { name version }
}
byStageStatus: runs(filter: { stages: { some: { status: { eq: ERROR } } } }) {
results { id title }
}
byParent: runs(filter: { parent: { blueprintName: { eq: "coder" } } }) {
results { id parentId }
}
}A whole subtree is a filter on ancestorIds, which every run carries root first:
ancestorIds: { has: "<id>" }. children walks one level per nesting, and this is the flat read
of every level at once.
What a file-backed filter costs
Most of RunInput is answered from an index the server keeps in memory, so a predicate over
status, spend, timing and blueprint name opens nothing. Some fields live in the run's own
directory: context, finalOutput, stages, blobs, artifacts and the search scopes below.
{
runs(filter: {
status: { eq: COMPLETE }
context: { totalTokens: { gt: 100000 } }
finalOutput: { content: { contains: "TODO" } }
}, first: 25) {
results { id title context { totalTokens maxTokens } }
cursor
}
}The walk is lazy and it is ordered. Cheap fields are tested first and drop what they can with no file opened. What is left is sorted, the cursor is seeked past, and only then are files read, in order, until the page is full. There is no scan cap and no truncation flag, so a selective filter over a large store may take a while and will answer completely.
Nothing before the cursor is read twice. Page two opens no file belonging to a run page one already returned, which is what makes paging through a file-backed filter cost the same per page as the first one did.
Searching the text
search is free text over a run listing, and it sits beside the filter rather than inside it. A
listing has one search, and nesting one in a combinator would be asking for a second.
{
runs(search: { query: "connection timeout", in: [META, LOGS] }, first: 10) {
results { id title status }
highlights { runId field snippet stageIndex }
}
}META and FILES are answered from the index and cost nothing. CONTEXT, LOGS and JOURNAL
read files, so they join the lazy walk above. Matching is case-insensitive substring: no regex and
no boolean operators, because the filter's own combinators say that better.
Ordering
orderBy is a list of keys, each a field and a direction, applied in the order written.
{
runs(
orderBy: [{ field: LAST_PROGRESS_AT, direction: DESC }, { field: TITLE, direction: ASC }]
first: 25
) {
results { id title lastProgressAt }
cursor
}
}Only fields that hold still are sortable, which is why RunOrderField names TITLE, STARTED_AT,
UPDATED_AT and LAST_PROGRESS_AT and not ageSecs. An age moves with the clock, and a sort key
that moves under a cursor makes a page skip or repeat.
Each listing declares its own keys, so BlueprintOrderField is NAME and VERSION, and a
journal-backed listing such as executions orders by JOURNAL_POSITION. Leave orderBy out and
each listing uses the one that suits it.
Looking one thing up
Every Node type has a singular root field beside its listing, and a lookup answers null for "not
here" rather than failing.
query Lookups($ids: [ID!]!) {
run(id: "coder-1788924523-abc123") { id title status error }
blueprint(name: "coder") { id name version digest source }
mcpServer(name: "docs") { id name transport endpoint }
script(ref: { kind: TOOL, name: "summarise", blueprintName: "coder" }) { id path compiles }
node(id: "coder-1788924523-abc123") {
id
... on RunOutput { title status ageSecs }
... on BlueprintOutput { name version }
... on UpdateJobOutput { status }
}
nodes(ids: $ids) {
id
... on RunOutput { status updatedAt }
... on ScriptOutput { kind name compiles }
... on ModelOutput { modelId providerName }
}
}node is for a client holding an id and no type: a webhook payload, a cache key, a link somebody
pasted into a ticket. nodes(ids:) is the same thing in bulk, one answer per id, in the order you
asked, and null where an id names nothing.
Nine types implement Node, and an id is unique across all of them, which is what lets node work
the type out and what makes an id safe as a cache key.
| Type | Its id | Example |
|---|---|---|
RunOutput |
The run id, the same one every REST route takes | coder-1788924523-abc123 |
BlueprintOutput |
<name>@<digest prefix>, one id per revision |
coder@3f9a1c0d8e77 |
ScriptOutput |
script:<kind>:<name>, with @<blueprint> on the kind where it has one |
script:tool@coder:summarise |
McpServerOutput |
mcpServer:<name> |
mcpServer:docs |
YoloProfileOutput |
yoloProfile:<name> |
yoloProfile:careful |
ModelOutput |
model:<provider>/<model id> |
model:openai/gpt-5.5 |
ProviderOutput |
provider:<name> |
provider:anthropic |
UpdateJobOutput |
The job id startUpdate handed back |
update-1788924523-1 |
RunExportOutput |
The job id startRunExport handed back |
export-1788924523-0 |
A name on its own is never an id. A server, a profile and a script are each unique only within
their own kind, so their ids carry the kind as a tag. A model's carries the provider too, because a
model id is the provider's own and two providers can both serve gpt-5.5 while billing to
different places.
An id that names nothing answers null rather than an error. A deleted run, an export past its hour, a blueprint revision this machine no longer has and a typo are the same answer, and all four mean the same thing. A tagged id whose tag this server does not know answers null too, so a client written against a newer build degrades rather than breaking. What does fail is a read that could not answer at all, such as a config file that will not parse.
Reading a run
The summary fields come from one stat-cached read, so a listing of fifty costs fifty stats. Everything else reads a file in the run's directory, and only when you ask for it.
{
run(id: "coder-1788924523-abc123") {
id title status task ageSecs workingSecs iteration toolCallCount
usage { promptTokens completionTokens cachedTokens }
cost { costUsd costPricedUsd costIsExact unpricedCalls }
currentStage { name index of }
waitReason { reason needsAPerson blocker remedy outstanding }
flags { emptyOutput producedOutput maxIterationsHit modifiedFileCount }
metadata { key value }
}
}waitReason.needsAPerson is the field a fleet view wants. A run waiting on its own workers is
healthy and resolves on its own. A run waiting on an answer is a row somebody has to act on.
NEEDS_SETUP also carries a blocker and a remedy, so a client can offer the right fix rather
than parse a sentence.
What each stage ran on
A blueprint picks a model per stage and names a list to fall back through, so one run can honestly have used three providers. The stage ledger is where that is answered, stage by stage.
{
run(id: "coder-1788924523-abc123") {
stageModels { provider model }
stages(first: 20, orderBy: [{ field: INDEX, direction: ASC }]) {
total
results {
name index status entered visitCount
usage { promptTokens completionTokens }
cost { costUsd }
models { provider model }
regionPeaks { region tokens }
}
}
}
}models on a stage is null, never an empty list, where the stage has run no inference. A stage the
run never entered, one whose first call is still in flight, and one whose only provider could not be
reached all answer null.
stageModels on the run is the flat roll-up of the same thing, and it is what the filter reads:
stageModels: { some: { model: { contains: "opus" } } } keeps a run where any stage ran on one.
That is not the same question as "no stage ran on anything else", which is none or a not.
The sub-agent tree
{
run(id: "coder-1788924523-abc123") {
treeStatus { depth descendantCount rollup { promptTokens completionTokens } }
children(first: 25, orderBy: [{ field: STARTED_AT, direction: ASC }]) {
results { id title status cost { costUsd } }
cursor
total
}
}
subtree: runs(filter: { ancestorIds: { has: "coder-1788924523-abc123" } }, first: 50) {
results { id ancestorIds status }
total
}
}children is the run listing with this run preset as the parent, so it takes the same filter, the
same sort keys and the same cursors. treeStatus answers "what did this fan-out cost" without
walking it: the roll-up covers every run below, at any depth. A parent that spent little and whose
fifty workers spent a great deal is not a cheap run.
What a run did
A context window says what a model is looking at now. It does not say what the run tried.
executions reads the run's journal instead, so it holds the attempts the window no longer shows:
a call a gate refused, one that failed and was reissued, one a restart cut off.
{
run(id: "coder-1788924523-abc123") {
executions(first: 50) {
total
cursor
results {
id callId outcome stageIndex iteration dispatchedAt endedAt journalPosition
call {
__typename
toolName
rawArguments
... on ShellCallOutput { args { command } }
... on WriteFileCallOutput { args { path append } }
... on UntypedToolCallOutput { reason }
}
}
}
}
}One attempt is one execution. A call the model reissued after a failure is a second execution with
its own id, which is why these have ids of their own. A provider is free to reuse its callId
across a retry, so that field is correlation rather than identity.
outcome is null for three different reasons, and a client must not flatten them. The attempt may
still be running, it may have ended before this build recorded outcomes, or it may have ended in a
way only the result text describes. endedAt tells the first apart from the other two.
INDETERMINATE is its own answer: a daemon that died between dispatch and completion left a call
nobody saw the end of, and the resume that carried the run on records that.
journalPosition is where the record that dispatched the attempt sits in the journal, as a byte
offset. It only climbs within a run and it never changes, so it orders executions and names one for
as long as the run exists.
Every tool takes exactly one argument shape, so each tool has its own type and a mismatched pair
cannot be built. rawArguments is on every call, typed or not. The typed view is a convenience
over it and never a replacement, because a debugger that could only show the tidied version hides
the malformed call that caused the bug. An alias is typed as the tool it means, so bash comes
back as a ShellCallOutput whose toolName is still bash.
A call is an UntypedToolCallOutput for two reasons, and reason says which.
NO_TYPE_FOR_THIS_TOOL is an MCP or script tool, which is ordinary.
ARGUMENTS_DID_NOT_MATCH is a built-in whose recorded arguments did not fit its own schema, which
is worth looking at.
What an execution is connected to
An execution sits inside a stay in a stage and follows from one trip to a provider. Both are things you can ask for rather than pair up yourself.
{
run(id: "coder-1788924523-abc123") {
executions(first: 20, filter: { outcome: { eq: FAILED } }) {
results {
id
visit { id ordinal enteredAt leftAt inProgress }
requestedBy { attempt provider model outcome { kind } }
contextChanges { cause revisionAfter regions { region tokenDelta } }
producedArtifacts { name mimeType size url }
result { text bytes truncated parts }
}
}
}
}visit is the stay, and it is the key to correlate on. stageIndex says where an execution sat,
not which stay it belonged to: a stage entered three times has one index and three visits, and
iteration restarts on every entry.
requestedBy is the trip to the provider whose answer asked for the call. You cannot work it out
from the timeline, because a failover means the answer came from a different provider than the
attempt before it went to.
contextChanges is what this execution committed to the window, and it is independent of
outcome. A call that succeeded may have committed nothing, and a call that failed may have
committed something before it failed. Most executions commit nothing: the context_* and todo_*
tools are the ones that show up, along with anything that wrote a part into a region of its own.
result is its own field because one result can be a whole file, so a page of executions carries
none of them. bytes is the whole result's size and truncated says whether text is only its
head. parts names the stored parts the result carried.
Each of these is null or empty where the journal did not record the connection, and never a guess.
visit is also null past the ledger's per-stage cap of the earliest 128 stays, where the stay is
real and its detail is not kept.
What a run asked
executions says what a run tried. It says nothing about the calls a person had to approve, or the
free-form questions a stage asked along the way. Once a tool has read the answer, a granted call
looks exactly like one no policy ever stopped, so interactions is the only record that this run
stopped and asked somebody at all.
{
run(id: "coder-1788924523-abc123") {
openInteraction { id kind prompt options }
interactions(first: 50) {
total
cursor
results {
id kind prompt body stageName isRequired askedAt settledAt
toolName
settlement { outcome approved scope choice text feedback }
}
}
}
}A question is written down when it settles, so the one a run is parked on right now is not on this
list yet. openInteraction carries that one while it is open, and the approval inbox across every
run is openInteractions.
Every field on settlement but outcome is null unless outcome is ANSWERED. Nobody answered a
TIMED_OUT ask, a CANCELLED one or a REFUSED one, so there is nothing for the rest to carry.
REFUSED should never appear: it means a request never opened because another was already open
under the same id, which is a fault in the server rather than anything about the call.
scope is the one field worth a note against REST. This spells the widest grant RUN, where the
REST journal and answer routes write session. ONCE and STAGE spell the same on both sides.
A grant is keyed on what was approved, not on the tool alone: for a shell call that is the program
and its first literal argument, so RUN on touch a.txt does not cover touch b.txt, and the
second one asks again.
An unattended run asks nobody, so it has no interactions to list. An empty list on a run that plainly did something dangerous means exactly that.
What a run's provider calls took
usage and cost are per call that worked. A call refused three times and answered on the fourth
is billed once, so the time the run spent being refused is in neither of them. inferences is that
half, read from the same journal: one entry per trip to a provider, in the order the run made them.
{
run(id: "coder-1788924523-abc123") {
inferences(first: 50) {
total
cursor
results {
stage attempt provider model durationMs backoffMs at
outcome { kind finishReason stoppedFor failureKind transient capacity retry }
digest { systemHash messages tools maxTokens temperature }
failover { fromProvider fromModel toProvider toModel reason }
}
}
}
}outcome.kind is SUCCEEDED or FAILED. finishReason and stoppedFor belong to an attempt that
answered: finishReason is how the provider said the answer ended (complete, token_limit,
tool_call, stop, or unknown), and stoppedFor is the provider's own words for an unknown
one, such as a content filter Leviath has no name for. Both are null on a failed attempt and in a
journal written before they were recorded. The four fields after them are null unless it failed.
transient and capacity are how the failure was judged at the time rather than now, because what
counts as transient is a policy that moves between releases. retry says what the loop did next.
SAME_MODEL is the same provider again after a wait, and the next entry's backoffMs says how
long that wait really was. RENEWED_FILES is an immediate retry that uploaded the request's files
afresh, so it spends no wait at all.
digest identifies a request without carrying it. Two attempts with the same digest sent the same
thing, which is the question a retry raises: a provider that kept refusing reads differently from a
request that kept changing underneath the run. systemHash is opaque, so compare it and read
nothing into the value.
failover is the move to a different provider, and it is null on almost every attempt. A retry
against the same provider is the next entry, not a move. Where it is set, the attempt after it went
to toProvider and toModel, and reason says why the first provider was judged unusable.
What one call sent
modelInput is the request itself, per attempt. It is off by default, and captureStatus says
which of the four states a record is in before you read anything else from it.
{
run(id: "coder-1788924523-abc123") {
inferences(first: 50) {
results {
attempt
modelInput {
captureStatus bytes sourceContextDigest
toolCatalogVersion assemblyVersion
request
parameters {
temperature
maxOutputTokens { ... on MaxTokensCountOutput { tokens } }
providerParams
}
}
}
}
}
}RETAINED means request is there. NOT_CAPTURED means no body was ever taken, which is every
run nobody asked to capture. REDACTED means a body was taken and then scrubbed, and EXPIRED
means it was taken and then aged out. The last two are a different fact from NOT_CAPTURED:
something existed and is gone. modelInput itself is null for an attempt whose journal recorded
none.
Everything beside request is recorded whether capture is on or off, because it costs nothing and
answers what digest cannot. parameters is what the request really carried after every override
and clamp, so maxOutputTokens is an absolute count rather than the percentage a blueprint may
have declared. toolCatalogVersion distinguishes two attempts that offered different tools.
assemblyVersion moves when the meaning of an assembled request changes, so an old captured body
stays interpretable.
sourceContextDigest names the window the request was assembled from, which is how a captured
request joins to contextHistory. It is empty when no body was taken, because folding it walks the
whole window. bytes is the size of the captured body, so a client can show what capture cost even
once the body is gone.
request is Leviath's own request shape, not one vendor's wire body. The adapter turns it into the
vendor's JSON and never hands that back, so serving the vendor shape would mean rebuilding it, and
a rebuilt prompt is not the request that was sent.
There is no mapping from context regions to places in the request. Assembly does not keep one: conversation messages carry no region, one region can become several system blocks, and the blocks are then reordered by cache tier. A mapping would have to be inferred after the fact, and an inferred one is not evidence.
Turn capture on for a machine with [observability] capture_model_input, or for one run with
captureModelInput on spawnRun. Read the warning in
Observability first. A captured request
holds whatever the run's context held, including file contents a tool read and anything somebody
pasted, and there is no size cap.
Why a region changed
contextHistory serves snapshots of the window. contextChanges serves the changes that moved it.
Both read the same journal, and neither answers for the other. A region that lost its plan looks
identical in a snapshot, whether a compaction took it, a transform cleared it, or the model deleted
it.
{
run(id: "coder-1788924523-abc123") {
contextChanges(first: 50) {
total
cursor
results {
cause at journalPosition
revisionBefore revisionAfter executionId
regions {
region digestBefore digestAfter tokensBefore tokensAfter
tokenDelta entriesAdded entriesRemoved
}
}
}
}
}Each entry is one committed transaction, which may touch several regions. A compaction summarises one region and empties another, a stage edge clears four, a resume rebuilds every region there is. All of those are one change.
cause names a path through the runtime rather than a shape of edit. SEED, MESSAGE,
MODEL_REPLY, TOOL_RESULT, PRODUCED_PART, COMPACTION, TRANSFORM, CONTEXT_TOOL, HOOK,
FAN_OUT, INTERACTION, RESUME and FRAMEWORK are the whole vocabulary. Two paths that both
append to the conversation stay two causes, because which of them ran is the question being asked.
A change carries no content, because the snapshot recorded on the same tick already holds the text.
The per-region digests are what tell you whether to go and read it: digestBefore equal to
digestAfter means that region ended the transaction holding what it started with. tokenDelta is
negative where a region shrank, and entriesRemoved counts any eviction the change triggered.
An empty list means the journal holds no change records. A write whose path cannot name its cause records nothing rather than borrowing the nearest neighbour, so a gap here reads as a gap.
A window, now or by name
{
run(id: "coder-1788924523-abc123") {
context { revision totalTokens maxTokens stageName regions { name kind tokens } }
contextHistory(first: 20, orderBy: [{ field: SEQUENCE, direction: DESC }]) {
total
cursor
results { at stage window { revision totalTokens maxTokens } }
}
contextSnapshot(revision: "cw1-4f2a9c8e5b1d7063a4e2f8c19d0b6537") {
at stage
window { revision totalTokens regions { name tokens } }
}
}
}Each point of contextHistory carries a whole window, so it is paged harder than the run listing
is. Ask for the regions you draw rather than every point's every region, because
regions { content } is the text itself.
A revision is a content address, derived from what the window holds, so it names that content for
ever. contextSnapshot resolves one to exactly the content it was minted from, never to whatever
the run holds now. Null means this run never held that window, which is also what a revision from
another run looks like. Two points holding identical contents share a revision, and the read
answers with the first time the run held it.
Files, logs and bytes
{
run(id: "coder-1788924523-abc123") {
recorded: files(first: 50) { results { name exists } isModifiedFilesTruncated }
onDisk: files(source: WORKDIR, path: "src", first: 50) {
path parent workdir isTruncated
results { name isDir size mimeType }
}
fileContent(path: "out/report.md") { content offset nextOffset truncated }
current: logs(tailBytes: 4096)
everyStage: logs(stage: { all: true }, stream: OPERATIONAL, tailBytes: 4096)
blobs(first: 20, filter: { mimeType: { startsWith: "image/" } }) {
results { sha256 mimeType name size width height tokens regions stored url }
}
artifacts(first: 20) { results { name mimeType size sha256 url } }
fileUrl(path: "out/report.pdf", download: true)
blobUrl(sha256: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
}
}MODIFIED, the default, is the run's own record of what it changed. It is free, because it is
already in the run's record, and it is a claim about the run rather than about the disk.
isModifiedFilesTruncated says when the run hit its tracked file cap, and a path since deleted is
listed with exists: false rather than dropped.
WORKDIR is what is there now, one directory level per request. Pass an entry's own path back to
go a level down. That bound is the answer to a repository with a node_modules in it, where one
request trying to enumerate everything is no answer at all.
fileContent reads text, at most a megabyte at a time, because the answer travels inside this one.
Pass nextOffset back as offset for the next window, and the windows concatenate into the file.
logs reads from the end of a stream. stage takes exactly one of index and all, and leaving
it out means the stage the run is on now. stream picks OUTPUT or OPERATIONAL, and tailBytes
bounds the read, under the server's own cap because naming every stage multiplies it by the stage
count.
Bytes never ride a query answer, which is why blobs and artifacts above carry metadata and a
url rather than the bytes. fileUrl mints the same kind of link for any file in the working
directory, and blobUrl for a hash a client already holds.
A signed link carries its own permission, so it works in an <img src> or a download link, where a
header cannot be set. What it is not is your API token in a URL. It opens one path, it lasts five
minutes, and it opens byte routes only. The signing key is random per server process and never
written down, so a restart invalidates every link it handed out. Links are relative, so they keep
whatever host, scheme and port you reached the server on.
Blueprints and the manifest
Two different questions, and the schema keeps them apart. blueprints lists what is installed on
the machine now. blueprint on a run is the manifest that run executed, read from the run's own
copy.
The whole manifest is readable, one field at a time. That covers a stage's model block, its tool routing, its checkpoints, its output shape, its hooks and its fan-out. It covers the edges out of a stage too, with their conditions and gates.
{
blueprint(name: "coder") {
id name version description digest source maxChildDepth toolRescan
dependencies { name kind required remedy }
regions { name kind maxTokens budgetPercent volatility admission }
stages {
name mode maxIterations availableTools
model { allowUserDefault requestTimeoutSecs models { provider model } }
transitions {
targetName condition
target { name }
gate { requireRegionNames maxAttempts requireRegions { name } }
stuck { afterIterations afterMinutes afterToolCalls }
}
interactionPoints { name prompt style options required }
fanOut { maxWorkers maxItems onWorkerFailure workerStageName workerStage { name } }
effective { includesBatchHint shellHintEligible tracksTaint }
}
}
run(id: "coder-1788924523-abc123") {
blueprintDigest
blueprint { id name version source digest }
}
}A run copies its manifest into its own directory at spawn and records that copy's digest. So editing or deleting the installed blueprint never changes what a finished run says it ran, and a daemon restart resumes a run on the manifest it started with. Only the manifest is frozen. Scripts it names, such as hooks and validators, are still read from the installed blueprint's directory.
source says which file a blueprint came from, SNAPSHOT or INSTALLED. A run recorded before
snapshots existed has no copy, so it reads INSTALLED and its blueprintDigest is null. What it
executed is unknown, which is not the same as "unchanged".
The id is <name>@<digest prefix>, not the bare name. Two revisions of one name are two different
objects, so a client that caches by type and id cannot merge a run's frozen copy with whatever is
installed now.
Two rules run through the manifest, because it is a document rather than a database.
A setting the author left out is null, even where the daemon has a default for it. What was written
and what the daemon resolves are different questions. StageOutput.effective answers the second:
the batch hint, the shell hint, the nudge, the sandbox and taint tracking, each resolved stage over
blueprint over this machine's config.
A region or a stage the manifest names is served as the object it names, with the written name kept
beside it. targetName, requireRegionNames and defaultRegionName carry what the author wrote,
whether or not a declaration was found. Two things put a name there with nothing to resolve. A
later edit can remove the region, which lev validate refuses and the daemon will not spawn. And
the runtime carries conversation, tool_results, final_output and stage_instructions whether
a manifest declares them or not.
A blueprint's own tools and scripts are fields on the blueprint, because the scope changes which directory is walked.
{
blueprint(name: "coder") {
tools(first: 50) {
results {
name origin description
... on ScriptToolOutput { path blueprint requires }
}
skipped { path reason }
total
}
scripts(first: 50) {
results { id kind name scope path relativePath isDeclared compiles compileError }
}
}
}Tool is an interface with two members, BuiltinToolOutput and ScriptToolOutput. A script
always has a file and a built-in never does, so the file is a field on the one that has it rather
than a null on both. Sub-agent tools are built-ins with origin: SUBAGENT, because they carry the
same four fields and a type of their own would say nothing a client could act on.
skipped names scripts that were found and could not be offered, with the reason, because a tool
an author believes exists and silently is not there is the failure worth reporting.
A tool stays a name inside a manifest. available_tools may name an MCP server's tool, a group
token such as @builtin, and any tool this machine does not have, so the inventory describes what
is here rather than what was written.
Mutations
Every mutation has the same shape. One argument named request, and a result that carries what
changed.
mutation {
pauseRun(request: { id: "coder-1788924523-abc123" }) {
run { id status }
warnings
}
}| Rule | What it means |
|---|---|
| Verbs | create, update, delete, upsert, start, check, signIn, and plain imperatives for run actions |
| Result | It carries the entity under its own name. A delete carries deletedId or deletedIds |
| Booleans | No mutation answers a bare Boolean. A result carries the thing instead |
| Failure | A refusal is a GraphQL error with extensions.code, never a field inside a result |
| Exclusive input | Spelled with @oneOf, so the schema refuses a second variant rather than the server |
| Sweeps | A destructive bulk mutation takes filter and refuses an empty one |
checkMachine is the one field with no request, because it has nothing to say: the checks are
the checks.
spawnRun is the widest request, and every part of it is optional but the blueprint and the task.
mutation Spawn($task: String!) {
spawnRun(request: {
blueprint: { name: "coder", digest: "3f9a1c0d8e77" }
task: $task
workdir: "/work"
model: "claude-sonnet-5"
maxDepth: 3
yolo: { profileName: "careful" }
captureModelInput: true
regions: [{ region: { name: "brief" }, text: "ship the parser fix" }]
metadata: [{ key: "ticket", value: "LEV-412" }]
attachments: [{ path: "spec.pdf", deliver: NATIVE, caption: "the spec" }]
callback: { url: "https://example.invalid/hooks/leviath", secret: "shared-secret" }
output: { format: "markdown", instructions: "one page, no preamble" }
}) {
run { id status task }
warnings
}
}The blueprint argument is a BlueprintRef rather than a name, so it can carry the revision you
mean. Add the digest you read off BlueprintOutput.digest and the spawn is refused with
CONFLICT if something else is installed under that name. Send the name alone and the spawn takes
whatever is there.
yolo is @oneOf: exactly one of everything and profileName. callback puts the secret
inside the object that carries the URL, so a secret with nowhere to go cannot be written.
attachments name files inside the working directory, and deliver says whether each goes to the
model natively, as text, or as a stand-in.
warnings names checks the blueprint declared that this request's own output shape retires. Three
refusals here are the server's rather than the daemon's, and each answers FORBIDDEN: a workdir
outside --workdir-root, an unattended run on a --no-remote-yolo server, and a callback URL the
outbound policy will not allow.
Acting on many runs at once
pauseRuns, resumeRuns, cancelRuns and deleteRuns take a filter, the same RunInput the
listing takes. A run the act does not apply to is reported under skipped rather than failing the
sweep.
mutation {
cancelRuns(request: {
filter: { status: { eq: WAITING_INPUT }, startedAt: { lt: 1758326400 } }
}) {
runs { id status }
skipped { id reason message }
}
deleteRuns(request: { filter: { updatedAt: { lt: 1758326400 } }, force: false }) {
deletedIds
skipped { id reason message }
}
}SkipReason is STILL_RUNNING, RECORD_UNREADABLE, ALREADY_FINISHED or OTHER, with a
message beside it. Partial success is the normal outcome here, not a failure.
runs carries each run as the act left it, waited for the same way the single-run acts wait. The
daemon moves a run in its world and the record is written a moment later, so the sweep reads the
records back once every act has gone out. A run that ends by itself while the sweep is reaching it
is ALREADY_FINISHED under skipped and never under runs, so nothing this sweep did overwrites
what the run says about itself.
An empty filter names every run on this machine, and every one of these four refuses it with
BAD_USER_INPUT. Deleting everything is a thing to ask for outright, not something a client falls
into by sending a filter it forgot to fill in.
Deleting a run takes its sub-agents with it, because their records only mean anything under the run that started them. Deleting a record is not editing a run, so a finished run is fair game here even though the lifecycle mutations refuse it.
Answering a prompt
openInteractions is the approval inbox: every open ask, each carrying the run it is parked on.
The daemon holds these in memory, so it is one read rather than a walk of the run store.
{
openInteractions(first: 50, orderBy: [{ field: SEQUENCE, direction: ASC }]) {
results {
id kind prompt body options isRequired
run { id title status }
toolCall {
__typename
toolName
... on ShellCallOutput { args { command } }
}
}
total
}
}The answer is @oneOf, so exactly one variant goes in, and which one the request's kind decides.
mutation {
answerInteraction(request: {
interactionId: "coder-1788924523-abc123-approve-1"
answer: { deny: { feedback: "read the file instead" } }
}) {
interactionId
outcome
}
}| Variant | For a request of kind |
|---|---|
choice |
MULTIPLE_CHOICE, zero-based |
text |
FREE_TEXT or EDIT_TEXT |
approve |
CONFIRM or TOOL_APPROVAL, with a scope |
deny |
CONFIRM or TOOL_APPROVAL, with optional feedback |
feedback is what the model reads instead of the call, which is why it sits on the denial rather
than beside an approval. The first answer wins, and a second one comes back ALREADY_SETTLED
rather than as an error, because two people clicking one prompt is ordinary.
Blueprint writes
mutation {
updateBlueprint(request: {
blueprint: { name: "coder", digest: "3f9a1c0d8e77" }
manifest: "[agent]\nname = \"coder\"\nentry_stage = \"analyze\"\n"
}) {
blueprint { id name version digest }
}
}A name that is already installed is a CONFLICT on createBlueprint, because replacing somebody's
blueprint is what an edit is for. The digest on the reference pins the revision being replaced,
so two clients editing one blueprint cannot silently overwrite each other. deleteBlueprint
answers with deletedId and leaves every run that used it intact, because each run holds its own
snapshot.
The checks are queries rather than mutations. validateBlueprint, validateScript and
validateProviderKey take text and give a verdict, writing nothing and dialling nothing. A form
usually calls one just before a write, which is where it sits on the screen, not what it does.
{
validateBlueprint(manifest: "[agent]\nname = \"coder\"\n", as: { name: "coder" }) {
valid errors warnings
}
validateScript(kind: TOOL, content: "fn run(args) { args.text }") { valid error }
validateProviderKey(provider: "openai", key: "sk-not-a-real-key") { valid message }
}A manifest that will not install comes back valid: false with the reasons, because the request to
check it succeeded. Passing as checks the text as that installed blueprint, so its own scripts
resolve.
Exports
An export is started here and fetched over REST, because the filter already lives here. See
exporting the whole store for startRunExport,
runExport(id:) and the JSONL the file holds.
Config and admin
Everything updateConfig writes reads back under the same name, so a settings screen renders what
it saves. A key is the one exception, and it reads back as hasKey on its provider.
{
config {
routing { defaultProvider providerOrder overrideModel fallbackModel }
providers { id name auth isEnabled hasKey baseUrl region }
gateways { name kind baseUrl hasApiKey headerNames models unknownKeys }
allowsFileUploads blueprintPaths mcpServerCount
server {
apiVersion capabilities isAdminEnabled
limits { maxPageSize maxUploadBytes requestTimeoutSecs maxConcurrentRequests }
}
health { savedAt error { kind path message line column } }
yoloFile { path exists error }
}
yoloProfiles(first: 20) {
results {
id name default questions checkpoints gate
toolRules { allow ask deny }
shellRules { allow { command args } ask { command } deny { command } }
}
total
}
}Read config.server.capabilities before choosing a code path. A 404 also means "no such run", so
discovering a feature by being refused costs a round trip and tells you less.
A write is a partial edit in five parts, and what none of them mentions is left alone.
mutation {
updateConfig(request: {
set: { providerOrder: ["openai", "anthropic"], allowsFileUploads: true }
clear: [OVERRIDE_MODEL]
providers: [{ provider: "openai", key: "sk-not-a-real-key", isEnabled: true }]
upsertGateways: [{
name: "local"
kind: OPENAI_COMPATIBLE
baseUrl: "http://127.0.0.1:1234/v1"
models: ["qwen3-coder"]
}]
deleteGateways: ["old-proxy"]
}) {
config {
routing { providerOrder overrideModel fallbackModel }
gateways { name kind models }
providers { id hasKey isEnabled }
}
}
}set names the settings to change, and a field left out of it is untouched. clear is the third
state, spelled in the schema as ConfigClearable rather than hidden in a null. Taking
overrideModel back to nothing is a value you send, not an absence the server has to guess at.
Every refusal happens before anything is written, so a request that is going to fail leaves the
file as it was.
The admin group is mounted only with --allow-admin. Without it those fields are invisible to
introspection and refused with FORBIDDEN if you name one anyway. The hiding is a courtesy and the
refusal is the boundary. The published schema documents them either way, because it describes what
the API is rather than what one server will do.
mutation {
createMcpServer(request: {
server: {
name: "docs"
transport: { stdio: { command: "npx", args: ["-y", "@acme/docs-mcp"] } }
env: [{ key: "DOCS_TOKEN", value: "t0ken" }]
}
}) {
mcpServer { id name transport endpoint command args envNames auth configError }
}
upsertMimeRow(request: {
row: {
mimeType: "application/x-acme-scene"
family: "model"
extensions: ["scene"]
magic: "41434D45"
tokens: { fixed: 2000 }
}
}) {
isNew
mimeRow {
mimeType origin blueprintName family extensions magic check
tokens { __typename ... on FixedOutput { tokens } }
}
}
upsertScript(request: {
script: { kind: TOOL, name: "summarise", blueprintName: "coder" }
content: "fn run(args) { args.text }"
}) {
script { id kind name scope path relativePath compiles compileError }
}
}Each of those answers with the row as the server now holds it, so a client never has to guess what
its write became. Secrets are the exception and they read back as names: envNames and
headerNames on a server, hasApiKey on a gateway, hasKey on a provider.
McpTransportWrite is @oneOf, so a server is stdio or HTTP and cannot be half of each. A server
whose config will not parse reads back with transport: null and a configError, which is a
different answer from "no such server".
Adding an MCP server writes a command Leviath then spawns, for this run and every future one. Writing a script writes code a run then executes. That is why the group is behind a flag rather than behind the API token alone.
Anything that dials out is a mutation, so queries stay side-effect free. refreshModels,
checkMachine, checkProvider, checkMcpServer, checkEndpoint, signInProvider and
signInMcpServer each cost seconds and a request to somebody else.
mutation {
signInProvider(request: { provider: "anthropic" }) {
authorizeUrl isAlreadyWaiting
provider { name signedIn account plan expiresAt }
}
checkEndpoint(request: { baseUrl: "http://127.0.0.1:1234/v1" }) { modelIds }
refreshModels(request: { provider: "openai" }) { models { id modelId maxContextTokens } }
}A sign-in answers as soon as there is a URL to go to, because what happens after that is the
person's business. The browser has to be on the serving host, since the flow listens on a loopback
port there, and one sign-in runs at a time because a second could not bind that port. Asking again
while one is waiting answers the same URL with isAlreadyWaiting: true.
yoloProfiles is a listing like any other, and yoloProfile(name:) reads one. A profile carries
its rules rather than a count of them: toolRules and shellRules each hold the allow, ask
and deny lists as written. Where the file is and whether it loads lives on
config.yoloFile, so "no profiles yet" reads differently from "the file is broken" without that
status being repeated on the listing.
decide on a profile answers what it would do with one call, running nothing. It is the same code
path lev yolo test takes, so the command and the API cannot disagree about a call.
{
yoloProfile(name: "careful") {
decide(tool: "shell", kind: BUILTIN, args: { command: "curl https://example.com" }) {
tool configured policy reason
}
}
}upsertYoloProfile writes one profile, whole. A profile is a grant of permissions, so an edit
that left half of a previous list behind would describe rules nobody wrote. Only that one table of
yolo.toml is touched, so comments and formatting around it survive; the comments inside the
table being written do not. The whole document is checked before anything reaches the disk.
mutation {
upsertYoloProfile(request: { profile: {
name: "builder"
default: ASK
questions: ASK
toolRules: { allow: ["@builtin"], ask: ["web_fetch"] }
shellRules: { allow: [{ command: "cargo *" }], deny: [{ command: "curl" }] }
} }) {
isNew
yoloProfile { id name toolRules { allow ask } shellRules { deny { command } } }
}
}deleteYoloProfile(request: { name }) takes one out and answers with the id it had. A name the
file has no table for is a miss rather than a silent success. Whatever sat above the table it
removed stays in the file, so deleting the first profile does not take the file's own header.
Both of these read the file as it stands before they touch it. A yolo.toml that will not parse,
or that another profile has made unloadable, is UNPROCESSABLE: your request is fine and the file
cannot answer. BAD_USER_INPUT is what the profile you sent earns, such as a reserved name or a
shell rule that will not compile.
The machine itself
{
models(filter: { providerName: { eq: "anthropic" } }, first: 50) {
results { id modelId providerName maxContextTokens limitsSource pricing { inputPerMtok } }
total
}
providers(first: 50) { results { id name display enabled signedIn account } }
toolGroups { name description }
doctor { ok isLive checks { name ok detail } }
directory(path: "/work", includeHidden: false) { path parent home cwd entries }
daemon {
reachable version build pid restarts restartAdvised
journal { healthy appendsAttempted appendsFailed queueDepth lastError { runId message at } }
}
updatePlan {
version installMethod channel latest updateAvailable checkedAt
binary { __typename ... on UpgradeByCommandOutput { shell commands } }
blueprints { name version change preselected }
}
serverTime
}models answers from the catalogue this server keeps, so it costs no provider call.
refreshModels is the mutation that goes and asks. Two providers can serve the same model id and
bill to different places, so the provider is part of each model rather than something you infer.
providers lists the ones a person signs in to through a browser, which is what signInProvider
and signOutProvider act on. A provider that takes an API key is never signed in to, so it is not
here: config { providers { ... } } is where every provider this build knows is listed.
enabled and signedIn are different questions. A provider can be turned on with no credential
stored, and a credential can outlive the config entry that used it.
doctor runs the environment checks that read config alone. A failing check is ok: false inside
a healthy answer, never an error: the request succeeded, and what it found is the answer.
checkMachine is the mutation that also dials a provider and the daemon.
daemon is answered from what this server already knows, so it works while the daemon is down,
which is the point of asking. reachable: false does not mean requests fail, it means the live
frames have stopped. journal is the one field there that needs the daemon, and it costs a control
call only where it is selected. A daemon whose journal is refusing writes serves every other field
exactly as before, and healthy stays false for the life of the daemon once a write has been lost.
updatePlan never reaches the network. The "is there anything newer" half is whatever the last
check found, and asking starts another for whoever asks next rather than waiting on one, so a page
can ask every time it opens. startUpdate runs it, updateJob(id:) follows one job and
updateJobs lists them.
serverTime is the daemon's clock in unix epoch seconds. Every duration a run reports is measured
against it, so a client drawing its own clocks should draw them against this rather than the
browser's.
Live frames
GET /ws/graphql carries subscriptions over graphql-transport-ws. Authenticate with ?token=,
because a browser cannot put a header on a WebSocket handshake.
Three streams rather than one, because they answer three different questions: what runs are doing, what the machine is doing, and how one update is going.
subscription Watch($filter: RunInput) {
runEvents(
filter: $filter
includeDescendants: true
types: [RUN_STATUS_CHANGED, LOG_LINE_WRITTEN, INTERACTION_OPENED]
) {
__typename
... on SubscriptionOpenedEvent { seq at serverInstance daemon { reachable version } }
... on RunStatusChangedEvent {
seq at runId status stage iteration
waitReason { reason needsAPerson }
}
... on LogLineWrittenEvent { seq at runId line }
... on InteractionOpenedEvent {
seq at runId
interaction { id kind prompt options toolName toolCall { __typename } }
}
... on DaemonLinkChangedEvent { seq at connected restarted restartAdvised }
... on EventsDroppedEvent { seq at count }
}
}The run filter is the listing's own filter, so "every failed run of this blueprint" is the same
words here as in runs. It is resolved to a set of runs when the subscription starts, and after
that the set only grows. A run that spawns is checked once its record exists, a run outside the set
is checked again on each status change, and includeDescendants puts sub-agents in scope as they
spawn. A run that stops matching keeps sending, because losing the frame that says a run finished
is worse than one extra row.
Only the filter's in-memory half decides those later checks. A condition that would have to open a file reads as "not matching" for now, and is asked again on that run's next frame.
Every frame carries seq and at, and both come from Event, which every frame implements, the
two transport ones included. RunEvent adds runId, agentId and the run itself, and only the
frames about a run implement it, so one fragment reaches the fields every domain frame shares and
another tells domain from transport.
The first frame of every subscription is a SubscriptionOpenedEvent. It says which process is
numbering the stream and whether the daemon behind it is reachable, so a client knows the stream is
live without a second request. Two subscriptions reporting different serverInstance values were
served by different processes, so a reconnecting client re-reads rather than resumes.
seq is that process's own numbering of every frame it sends, not a count of the ones this
subscription received. Two subscriptions open at once see one frame under one number. A
subscription that asked for three frame types sees the numbers of those three and nothing between,
so gaps are the ordinary shape of a filtered stream and mean nothing on their own.
Delivery is at-most-once, and a gap is announced rather than inferred. EventsDroppedEvent says
you fell behind, and count is how many frames went past unread. It is counted off the server's
numbering, so it includes frames your filter would have dropped anyway: an upper bound, never an
under-count. The broadcast is bounded, and a listener that cannot keep up is skipped past rather
than allowed to hold up the daemon. Treat it as the cue to re-read whatever you render.
DaemonLinkChangedEvent arrives whatever types says and whatever the scope is, because a run's
frames stopping looks exactly like a quiet run without it.
The machine's own frames are their own stream, so a settings screen subscribes without seeing a fleet's worth of log lines.
subscription Machine {
machineEvents(types: [DAEMON_LINK_CHANGED, CONFIG_HEALTH_CHANGED]) {
__typename
... on DaemonLinkChangedEvent { seq at connected restarted restartAdvised }
... on ConfigHealthChangedEvent { seq at healthy path error { kind message line } }
... on UpdateStepChangedEvent { seq at jobId step status detail }
... on SubscriptionOpenedEvent { seq at serverInstance }
... on EventsDroppedEvent { seq at count }
}
}One update has a stream of its own, narrowed to that job, because a console watching an install is watching one install.
subscription Installing($id: ID!) {
updateJobEvents(id: $id) {
__typename
... on UpdateStepChangedEvent { seq at step status detail }
... on UpdateFinishedEvent { seq at status restartRequired job { id status } }
... on SubscriptionOpenedEvent { seq at serverInstance }
}
}The frame that says the job finished is the last one it produces. The stream itself stays open until the client closes it.
There is no replay. A stream is how a client stays current, not how it reconstructs the past, and for that it reads the run.
Failures
A failure inside a field is a 200 with an errors entry. Each entry carries the path in your
query that produced it, so a page of fifty runs where one record will not read still returns the
other forty-nine.
Branch on extensions.code, never on the message text. Every failure carries one, a query refused
before any resolver ran included: those are BAD_USER_INPUT.
| Code | Means | REST answers |
|---|---|---|
BAD_USER_INPUT |
The request is wrong as written. Sending it again unchanged fails the same way | 400 |
FORBIDDEN |
This server is configured to refuse it, such as a workdir outside --workdir-root |
403 |
NOT_FOUND |
Nothing by that name, or nothing in the state the act needs | 404 |
CONFLICT |
It exists and its state refuses the change | 409 |
PAYLOAD_TOO_LARGE |
An attachment is over this server's max_upload_bytes |
413 |
UNPROCESSABLE |
Well formed, and something on disk will not answer, such as a yolo.toml a profile you never touched has broken |
422 |
UPSTREAM |
Something this server depends on answered badly. Retrying may well work | 502 |
DAEMON_INCOMPATIBLE |
The daemon was updated under a running server. Restart lev serve |
502 |
DAEMON_UNAVAILABLE |
The daemon could not be reached. Get it back, then retry | 503 |
INTERNAL |
Something failed that you did nothing wrong to cause | 500 |
extensions.httpStatus carries that same number, so a client that already knows the REST
vocabulary needs no second table.
Nothing inside a result is a failure. A bulk sweep reports what it did not touch under skipped, a
run that ended by itself while the sweep was reaching it reports itself as ALREADY_FINISHED, and
a check that found something wrong is a report rather than an error. Those are outcomes, and a
refusal is an error.
Limits
A query is checked before any of it runs.
| Limit | Value | Why |
|---|---|---|
| Selection depth | 12 | A run's children are runs, so nesting has no natural end |
| Complexity | 10000 | Depth does not bound breadth: fifty runs each asking for fifty children is shallow and large |
| Filter depth | 16 | and, or and relations nest, and a predicate is walked before anything is read |
| Filter values | 512 | One filter holding thousands of values is a query to split, not a page to serve |
first |
Per listing | The page caps above, refused rather than clamped |
The two filter limits are counted in the same walk that builds the cursor, so a filter that is over them is refused before a single run is touched.
Complexity counts the rows a query asks for. A listing field costs its first times what one row
of it costs, so runs(first: 200) { results { children(first: 200) { results { id } } } } is
counted as the forty thousand records it asks for and refused. The same query with first: 20 at
both levels costs four hundred and runs.
Every refusal in this section is a BAD_USER_INPUT with httpStatus 400. So is every refusal a
query earns before a resolver runs: an unknown field, an unknown argument, a misspelled enum value,
a @oneOf input with two members set, or a document that will not parse.
The keys of every object come back in the order you selected them, from the root down and through every fragment, as the spec asks. Fields are still resolved together rather than one after another, so a field that reads the disk costs the same whether it is first or last in the query.
The schema
The schema is generated from the server's own types, so it cannot describe something the server does not serve. It is the only one of Leviath's published schemas that is: the OpenAPI spec, the blueprint schema and the config schema are written by hand and held to the code by tests. Read it three ways:
- The published file,
leviath.graphql. It is committed, so no command is needed to read it, and a test refuses a build whose schema has moved away from it. Each channel publishes its own copy. - Introspection, which any GraphQL client tool can read live from your own server.
lev serve --print-graphql-schema, which prints what your build serves and exits.
Every type, field, argument and enum value in it carries a description. A client tool that renders documentation renders the whole thing, and an agent reading the SDL cold has the same answers a reader of this page does.
New fields and types are added; nothing is removed without being marked deprecated first. Check the
graphql capability in GET /api/config before choosing this transport, the same way you check any
other feature.