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.

bash
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:

graphql
{
  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.

graphql
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:

graphql
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
graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
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.

graphql
{
  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.

graphql
{
  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

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
{
  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

graphql
{
  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

graphql
{
  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.

graphql
{
  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.

graphql
{
  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.

graphql
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.

graphql
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.

graphql
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.

graphql
{
  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.

graphql
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

graphql
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.

graphql
{
  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.

graphql
{
  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.

graphql
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.

graphql
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.

graphql
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.

graphql
{
  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.

graphql
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

graphql
{
  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.

graphql
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.

graphql
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.

graphql
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.