Skip to content

Worker run API

A command-line bridge runs a Claude Code worker for each board event one of its rules matches. A worker that finishes reports itself through a tool call, so the board shows what it did. A worker that crashes, that is killed, or that never starts writes nothing.

These endpoints are where the bridge reports the run itself. The bridge reports each state of a run as it happens, from the moment it accepts the event to the moment the worker ends. The server keeps one row per run and a history of the states the run reached. A bridge built before run states reports each run once, when it ends. The project’s Worker runs page shows what the server holds.

Every endpoint on this page authenticates with an OAuth token that carries the agent scope. loupe login gets one. The firewall refuses a token that carries the site-review or the mcp scope.

The states of a run

StateWho sets itMeaning
queuedthe bridgethe bridge claimed the work request, and the run waits for a worker slot or for its card. A command run waits for its card only
replacedthe bridgea newer event for the same card and rule took the place of this run in the queue
resumedthe bridgethe ask the session waited on closed, and the bridge resumes the session
skippedthe bridgethe session already read every answer of its ask, so the bridge does not resume it
preparingthe bridgethe bridge runs the before command of the work entry, and the worker process has not started
runningthe bridgethe worker process started, or the command of a command run started
stoppingthe bridgea person asked the bridge to stop the run, and the worker process is still ending
stoppedthe bridgea person stopped the run. The bridge never resumes it
waiting-for-personthe bridgethe rule’s maxChain cap stopped the run. A move by a person starts a new run
droppedthe bridgethe bridge stopped, a rule died, or a reload removed the rule, while the run still waited
succeededthe bridgethe worker exited with code 0 with a structured result. A result from a new bridge has the status finished. A command run succeeds on exit code 0, with no result
no-resultthe bridgethe worker exited with code 0, with no structured result
unfinishedthe bridgethe worker exited with code 0 and the status unfinished: its work still runs or remains
blockedthe bridgethe worker exited with code 0 and the status blocked: it cannot go on without a person
waiting-on-forgethe bridgethe worker exited with code 0 and the status waiting: its work waits on the forge, such as the checks of a pushed pull request. The bridge does not resume the run
gave-upthe bridgethe run did not finish, and the bridge already ran every resume its rule allows
failedthe bridgethe worker exited with any other code. A command run that ran past its timeout, was killed or never started fails with the exit code -1
not-startedthe bridgethe worker process never started
timed-outthe serverthe bridge stopped sending its heartbeat while the run was open
lostthe serverthe bridge reconnected, and it no longer holds the run
closedthe serveran interactive run ended. See Interactive sessions

The bridge of this release sends none of replaced, resumed, skipped, waiting-for-person and gave-up, because Loupe decides each resume and the bridge no longer coalesces or caps runs. The server still accepts them, so a run from before the workflow engine keeps its history.

queued, resumed, preparing, running and stopping are open states. Every other state closes the run. succeeded, no-result, unfinished, blocked, waiting-on-forge, gave-up, failed and not-started are the outcomes. Only an outcome carries an exit code, a result flag, a result status, result fields and a failure reason.

stopped is not an outcome. It closes the run with no exit code and no result, and the bridge does not resume it. It can carry an output and a usage. A queued run can go straight to stopped, so it has no start time.

A structured result is the JSON object that claude returns for the schema the bridge passes. It holds a status of finished, blocked, unfinished or waiting, a summary, and any optional fields the rule asks for. A worker that exits with code 0 and gives none may have stopped before its work was done.

A bridge resumes a run that did not finish, on the same session, up to the cap of its rule. Each resume is a new run, which names the run it continues. The runs of one card that follow each other this way form a series.

Reporting a run state

PUT /api/projects/{handle}/worker-runs/{runId}

The handle is a project id or a project slug. A project name does not resolve. runId is a uuid the bridge generates for each event it accepts. The server identifies a run by its project, its bridgeId and its runId.

{
"bridgeId": "0199a0e2-9d4c-7c5e-9f2a-3b1c6d7e8f90",
"state": "running",
"at": "2026-09-13T10:00:00+00:00",
"subjectType": "card",
"subjectId": "0199a0e2-b1f3-7a44-9c11-2d3e4f506172",
"cardNumber": 42,
"workRequestId": "0199a0e2-c4d5-7e6f-8a9b-0c1d2e3f4a5b",
"workKind": "implement",
"ruleId": "implement-on-entry",
"sessionId": "5f0c2b1e-8d4a-4c3b-9e2f-1a0b3c4d5e6f",
"startedAt": "2026-09-13T10:00:00+00:00"
}

Every report carries the subject and the work request, so the first report the server reads can create the run. The subject is what the work is about. It is usually a card, and a module can name other subject types. The reports of one run can therefore arrive in any order.

FieldRule
bridgeIdrequired. A uuid the bridge generates once and keeps. It points at no table, so any uuid is accepted
staterequired. One of the seventeen states the bridge sets. The server refuses timed-out, lost and closed
atrequired. When the run reached the state, on the bridge clock, as an ISO 8601 timestamp
subjectTyperequired. What the run is about, such as card. It matches ^[a-z][a-z0-9-]{0,39}$. The bridge copies it from the work request
subjectIdrequired. The uuid of the subject, which is the card id for a card. It is a plain value, so a deleted card leaves its run history intact
cardNumberthe short number the card shows, counting from 1 inside the project, at most 2147483647. It is a label for a person, and never identifies the card. Required for a card subject, and refused for any other subject. The bridge leaves it out for another subject
workRequestIdthe uuid of the work request the run runs. A run of a person’s resume or rerun names the request of the run it continues
workKindthe kind of the work request, such as implement or fix. It matches ^[a-z][a-z0-9-]{0,39}$
ruleIdthe id of the workflow rule that opened the work request. It matches ^[a-z0-9][a-z0-9._-]{0,99}$
kindworker or command. A missing or null value means worker. See Command runs
sessionIdthe uuid of the session the worker runs as. Required for running, except on a command run. A harness that names its session with its own id sends that id in harnessSessionId
startedAtwhen the worker started, on the bridge clock. Required for running
endedAtwhen the worker ended, on the bridge clock. Required for an outcome. A stopped report may leave it out, and the server then uses at. The end, or at in its place, cannot be before the startedAt of the same report
exitCodethe process exit code, between -255 and 255. succeeded, no-result, unfinished, blocked and waiting-on-forge need 0, failed needs any other code, and not-started needs null
hasResultwhether the worker gave a structured result. no-result needs false, and succeeded, unfinished, blocked and waiting-on-forge refuse false. Send null for not-started, because a value is refused when exitCode is null. The server ignores it on a command run
resultStatusthe status of the structured result: finished, blocked, unfinished or waiting. It needs hasResult: true, so a command run cannot send it. blocked and unfinished need the state of the same name, waiting needs waiting-on-forge, and succeeded takes finished or null
resultReasonwhy the run ended the way it did, such as stacked or approval-stale. It has at most 40 characters of lower-case letters, digits and hyphens, and starts with a letter. It needs hasResult: true, as resultStatus does. The server stores a code it does not know as other
failureReasonwhy the process never started, at most 1000 characters. Required for not-started, and refused with an exit code
outputwhat the worker printed, at most 4000 characters. Required for an outcome, and it may be empty. A stopped report may carry it, and a run keeps its output when the report has none
askIdthe ask a resumed run continues, at most 100 characters
replacedBythe runId of the run that replaced a replaced run, a uuid
maxChainthe cap that stopped a waiting-for-person run, a positive integer
reasonwhy a run was dropped: shutdown, rule_dead or reload
resultFieldsthe optional fields of the structured result, as a JSON object of at most 4000 bytes. A list is refused
resumeSkippedwhy the bridge did not resume a run that did not finish, at most 50 characters. The bridge sends card_moved, shutdown, rule_dead or reload
continuesthe runId of the run that this run resumes, a uuid
usagethe tokens the worker spent. See Usage
workerPoolthe worker pool the bridge runs the worker in. It starts with a lower-case letter, and holds 1 to 40 lower-case letters, digits and hyphens, such as default
experimentthe experiment of the rule that ran the worker, such as impl-model. It matches ^[a-z0-9][a-z0-9_-]{0,63}$
variantthe variant of the experiment that the card runs with, such as sonnet. It matches the same pattern
requestedModelthe model the variant asked for, such as claude-sonnet-5-5, at most 100 characters with no control characters
switchedFromthe variant the card was pinned to before this run, when the rule no longer offers it. It matches the same pattern
harnessthe harness that runs the worker, such as claude-code or codex. It starts with a lower-case letter, and holds 1 to 40 lower-case letters, digits and hyphens
accountthe named harness account the worker uses. It holds 1 to 64 letters, digits, dots, underscores and hyphens, and starts with a letter or a digit. It is a short name, never a path or a key
modelthe model the harness runs, at most 100 characters with no control characters
harnessSessionIdthe id the harness gives the session, such as a Codex thread id. It holds 1 to 100 letters, digits, dots, colons, underscores and hyphens, and starts with a letter or a digit. Loupe keeps its own session id in sessionId

A gave-up report needs the exit code, the result flag and the status of the outcome the bridge would have resumed: failed, no-result or unfinished. So the run keeps the fault it had.

The server stores continues, workRequestId, workKind and ruleId from the report that creates the run, and ignores them on a later report. A new run of the fix kind queues the fix-run comment on the open pull request of its card. It resolves continues to a run of the same project and bridge, and stores no link for an unknown runId. It stores resultStatus, resultReason, resultFields and resumeSkipped from an outcome only.

The server stores workerPool from every report that carries it, a repeat of a state included, so the run keeps the pool that the last report named. A report with no workerPool keeps the stored pool. A bridge built before worker pools sends none, and its runs have no pool. The bridge can move a queued run to another pool before the run starts. Until the next report arrives, a queued run can show the pool it had before the move.

The server stores harness, account, model and harnessSessionId from every report that carries them, a repeat of a state included. A report with no value for a field keeps the stored value. So a value can arrive late: Codex names its thread only after it starts, and the model can come with the outcome. A blank value, or a value that breaks its rule, gets a 422. A bridge built before harnesses sends none of the four fields. The runs from before this release show the harness claude-code, because each bridge then ran Claude Code. A command run runs no harness, so it has none of the four. The interactive launch report takes the same four fields with the same rules.

The bridge sends experiment, variant, requestedModel and switchedFrom on running and on the outcome, never on queued. A run with no experiment sends none of them. A report that carries experiment must also carry variant, and requestedModel and switchedFrom need experiment. The server stores the four fields together from the first report that carries them, and ignores them on a later report. A not-started outcome also fills them, because the bridge resolves the experiment pin before it starts the worker. A blank value, a value that breaks its rule, or a field without its partner gets a 422.

The server checks the shape of askId, replacedBy, maxChain and reason, and it does not store them.

Command runs

A work entry with action: command runs a command with no agent, as Command action says. Each report of such a run carries "kind": "command". The server stores the kind from the report that creates the run, and ignores it on a later report. A value other than worker or command gets a 422.

A command run reports running with no sessionId. It ends as succeeded with the exit code 0, or as failed with any other code. A command prints no structured result, so the server ignores hasResult and refuses resultStatus. A command run has no session and no usage. A bridge built before command rules sends no kind, and its runs are worker runs.

Usage

An outcome can carry the tokens the worker spent, per model. claude -p --verbose --output-format stream-json returns them in modelUsage, on its result line.

{
"usage": {
"source": "reported",
"models": {
"claude-opus-5-5": {
"inputTokens": 1200,
"outputTokens": 340,
"cacheReadTokens": 56000,
"cacheWriteTokens": 7800,
"costUsd": 0.4321
}
}
}
}
FieldRule
usage.sourcerequired. reported when Claude Code gave the counts, estimated when the bridge counted them
usage.modelsrequired. An object keyed by model name, of at most 20 models. A name is 1 to 100 characters. An empty object means the worker spent nothing
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokensrequired. Integers of 0 or more
costUsdthe cost in US dollars, from 0 to 999999.999999. Send null for a model the bridge knows no price for. The server stores six decimal places

The server checks the shape of usage on every state, and stores it only from the outcome or the stopped report that closes the run. A repeat of that report writes nothing. A run with no usage has unknown usage, which is what a bridge built before the field leaves. A run whose models is empty spent nothing.

Reported counts replace estimated counts. Estimated counts never replace reported counts, and a second report from the same source changes nothing. The session usage report follows the same rule.

Send any timestamp in any offset. The server converts each one to UTC and stores it to the second.

Peak context

An outcome or a stopped report can carry peakContextTokens, an integer of 0 or more. It is the largest context of the main session during the run: the largest input_tokens + cache_read_input_tokens + cache_creation_input_tokens over the assistant lines of the stream whose parent_tool_use_id is null. The lines of subagents do not count. A line counts only when it holds all three fields, so a field that is missing reads as unknown, never as zero.

The bridge leaves the field out when no line counts, for example for a command run. The server stores a value from the outcome or the stopped report, and a report with no value keeps the stored value. A run with no value has an unknown peak.

How a report moves the run

A run moves forward only. The open states rank queued, then resumed, then preparing, then running, then stopping, and every closed state ranks above them. A report moves an open run when its state ranks higher than the state the run holds. A late running therefore does not move a stopping run back. A report never moves a closed run, with two exceptions:

  • A report replaces timed-out when its state ranks at least as high as every state the bridge reported before. A late queued therefore does not reopen a run that already ran.
  • A report replaces lost only when its state closes the run.

The server writes a history row for each state the run did not hold before, at the time in at. It writes the row also when the state does not move the run, so the history shows every state the bridge reported. A late running report still fills a missing session and start time.

A report is safe to retry. A state the run already holds changes nothing, and the server answers 200. A retry that reopens a timed-out run is the one exception. It writes a new history row, and the server answers 201.

StatusBodyWhen
201{"id":"<uuid>"}the state is new for this run, and the server wrote a history row
200{"id":"<uuid>"}the run already held this state, and the report changed nothing
401the request carries no token
403{"error":"insufficient_scope"}the token carries another scope, such as site-review
404{"error":"project_not_found"}the user has no project with that handle, and another user’s project counts as none
404agent push is switched off on the instance
422{"error":"invalid_run_id"}runId is not a uuid
422a problem object with a violations listthe body is invalid, and each violation names its field in propertyPath
429the token went over the rate limit. See Rate limit

id is the server’s own id for the run, which the Worker runs page shows as the attempt ID. It is not the runId.

Reporting the runs a bridge holds

PUT /api/bridges/{bridgeId}/runs

The bridge sends this report each time it connects to the hub. It lists every open run the bridge holds, across all its projects.

{
"runs": [
{
"runId": "0199a0e3-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"projectId": "0192f3a1-4b2c-7d3e-8f10-a2b3c4d5e6f7",
"state": "running"
}
]
}
FieldRule
runsrequired. A list of at most 1000 runs, which may be empty
runs[].runIdrequired. The uuid the bridge generated for the run
runs[].projectIdrequired. The uuid of the run’s project
runs[].staterequired. queued, resumed, preparing, running or stopping

The server compares the list with the runs of that bridge that are open or timed-out, in the projects the token’s user owns:

  • A run the list does not name becomes lost.
  • A timed-out run the list names takes the state the list gives.
  • An open run the list names does not change.
  • A run the list names and the server does not hold stays unknown. Its own state report creates it.

Each change writes a history row, stamped with the server clock.

StatusBodyWhen
204the report is taken
401the request carries no token
403{"error":"insufficient_scope"}the token carries another scope, such as site-review
404bridgeId is not a uuid, or agent push is switched off on the instance
422a problem object with a violations listthe body is invalid, and each violation names its field in propertyPath
429the token went over the rate limit. See Rate limit

Reporting the usage of a session

PUT /api/projects/{handle}/worker-runs/sessions/{sessionId}/usage

A session can run more than one worker process, because each resume runs a new one. The bridge sends this report when it knows the usage of those processes after their runs closed, such as from the session transcript.

{
"processes": [
{"source": "estimated", "models": {"claude-opus-5-5": {"inputTokens": 1200, "outputTokens": 340, "cacheReadTokens": 56000, "cacheWriteTokens": 7800, "costUsd": 0.4321}}},
{"source": "estimated", "models": {}}
]
}
FieldRule
processesrequired. A list of 1 to 100 usage objects, one for each worker process of the session, in the order the processes started
processes[]a usage object, with the rules of Usage
runIdoptional. The id of one interactive run of the session. With it, processes holds exactly one usage object, the usage of that run

The server finds the worker runs of that session in the project that have a start time, and orders them by startedAt. An interactive run, and a run that never started, is not a process. When the count of runs and the count of processes differ, the server writes nothing and answers 409. A session with no runs is such a mismatch.

The server stores startedAt to the second. When two of those runs start in the same second, the server cannot know which process came first. It then writes nothing and answers 409 with ambiguous_start_order.

Otherwise each run takes the process at the same place in the list, with the rule of Usage. The report fills a run with unknown usage, and replaces estimated counts with reported ones. It never replaces reported counts.

An interactive run has no worker process, so a report without runId never reaches it. When such a run closes, the server sends a collect-session-usage command to the bridges that can hold its transcript. See Pause and commands. The bridge that holds it sends this report with the runId of the command and one usage object. The server then gives that object to the interactive run of that id, in the project and the session of the path, with the same rule. The run must have ended. A run that still runs, a run of another session, a worker run and an unknown id count as no run, so the server writes nothing and answers 409 with process_count_mismatch. So does a list of more than one object.

StatusBodyWhen
200{"runs":2,"updated":1}the counts match. updated counts the runs whose usage changed
401the request carries no token
403{"error":"insufficient_scope"}the token carries another scope, such as site-review
404{"error":"project_not_found"}the user has no project with that handle, and another user’s project counts as none
404agent push is switched off on the instance, or the server has no such endpoint
409{"error":"process_count_mismatch"}the session has another count of started worker runs, or runId names no ended interactive run of the session, and the server wrote nothing
409{"error":"ambiguous_start_order"}two started worker runs of the session start in the same second, and the server wrote nothing
422{"error":"invalid_session_id"}sessionId is not a uuid
422{"error":"invalid_run_id"}runId is not a uuid
422a problem object with a violations listthe body is invalid, and each violation names its field in propertyPath
429the token went over the rate limit. See Rate limit

Reporting the tool calls of a run

PUT /api/projects/{handle}/worker-runs/{runId}/tool-calls

The bridge sends this report when a worker ends, after the final state of the run. runId is the uuid the bridge generated for the run. One request holds at most 500 calls, so a long run sends more than one. The last request also carries timing. Tool calls says what the bridge reads from the stream of the worker.

{
"calls": [
{
"seq": 1,
"tool": "Bash",
"kind": "shell",
"startedAt": "2026-10-06T14:12:03.120Z",
"durationMs": 840,
"isError": false,
"inSubagent": false,
"backgroundId": null,
"waitsOn": null,
"signatures": ["git status"],
"fullText": null
}
],
"timing": {"toolTimeMs": 412000, "idleGapMs": 0}
}
FieldRule
callsrequired. A list of at most 500 calls, which may be empty
calls[].seqrequired. The place of the call in the run, from 1 to 2147483647
calls[].toolrequired. The tool name, such as Bash, Read or Agent, of 1 to 64 characters
calls[].kindshell for a call that runs shell commands, subagent for a call that starts a subagent, and tool for any other call. The harness adapter of the bridge sets it, so the server reads no tool name. null from a bridge older than call kinds
calls[].startedAtrequired. When the call started, in any offset. The server stores it in UTC
calls[].durationMsan integer of 0 or more. null when the stream holds no result of the call
calls[].isErrortrue when the tool answered with an error. null when the stream holds no result of the call
calls[].inSubagentrequired. true when a subagent made the call
calls[].backgroundIdthe id of the background task the call started, of at most 64 characters
calls[].waitsOnthe backgroundId of an earlier call that this call reads, of at most 64 characters
calls[].signaturesrequired. A list of at most 20 strings of at most 120 characters. For a call of the kind shell, the program and its subcommand for each command. For any other call, the tool name
calls[].fullTextthe full input of the call, of at most 20000 characters. null unless the project collects full text
timing.toolTimeMsan integer of 0 or more. The time the main session spent in tool calls, with overlaps counted once
timing.idleGapMsan integer of 0 or more. The sum of each pause of more than 300 seconds between two timed lines of the stream

The insights.subcommand_programs feature flag holds the programs that keep a subcommand in a signature, as a comma list for the whole instance. You change it at /admin/feature-flags. Tool calls gives its default.

timing is optional, and a null value in it is unknown. The server stores timing on the run each time a request carries it.

The server finds the run by runId among the runs of the project, from any bridge. It stores each call whose seq the run does not hold yet, and skips the others. A retry is therefore safe, and it answers 200 with a lower stored. Each request then rewrites the metrics row of the run, as Run metrics describes.

StatusBodyWhen
200{"stored":12}the request is taken. stored counts the calls the server stored for the first time
401the request carries no token
403{"error":"insufficient_scope"}the token carries another scope, such as site-review
404{"error":"project_not_found"}the user has no project with that handle, and another user’s project counts as none
404{"error":"run_not_found"}the project has no run with that runId, or two runs of the project share it
404agent push is switched off on the instance, or the server has no such endpoint
422{"error":"invalid_run_id"}runId is not a uuid
422a problem object with a violations listthe body is invalid, and each violation names its field in propertyPath
429the token went over the rate limit. See Rate limit

The bridge reads a 404 with no error code as a server with no tool call endpoint, and drops that batch with no retry. The batches of later runs still try.

Run metrics

The server keeps a metrics row for each run. Besides the duration, the cost and the tokens, the row holds the timing of the run. The MCP tools worker_run_list and worker_run_get show it in metrics, and worker_run_tool_calls reads the calls one by one. A null value is unknown.

FieldValue
toolTimeMsthe timing.toolTimeMs the bridge sent
idleGapMsthe timing.idleGapMs the bridge sent
modelTimeMsthe duration of the run less toolTimeMs and idleGapMs, and never below zero. null when one of them is unknown
toolCallsthe count of the calls of the run, the calls of subagents included
failedCallsthe count of the calls with isError true
longestCallMsthe longest durationMs of the calls
subagentMsthe sum of durationMs of the calls of the kind subagent in the main session. null when a call of the main session has no kind
peakContextTokensthe peak context the bridge sent
meanCpuPctthe mean use of all cores, in percent, over the host samples of the bridge within the run
peakMemBytesthe largest memUsed of those samples, in bytes
peakSwapBytesthe largest swapUsed of those samples, in bytes
concurrentRunsthe count of the runs of the same bridge whose time overlaps the run, the run itself included
onBatterytrue when one of those samples has onAc false, and false when every sample with a known power source has onAc true

toolCalls, failedCalls, longestCallMs and subagentMs are null for a run with no stored call.

The host values read the host samples of the bridge from the start of the run to its end. The fact table stores them in the columns mean_cpu_pct, peak_mem_bytes, peak_swap_bytes, concurrent_runs and on_battery. meanCpuPct, peakMemBytes and peakSwapBytes are null when the bridge sent no sample within the run, or while the run has no end. A bridge sends none while the bridge.host_sampling_enabled flag is off, or when its rules.yaml sets collect: false. A run shorter than the sample interval can also hold none. onBattery is also null when no sample knew the power source. A sample that arrives after the run ended updates these values.

concurrentRuns needs no sample. It is null for a run with no bridge, no start or no end. A run with no end counts as an overlap only while it is preparing, running or stopping, so a lost run does not count forever. Loupe takes the count when the run ends, and does not change it when another run reports later. bin/console app:bridge:rebuild-run-facts counts every run again from the stored times.

Resolving an experiment pin

PUT /api/projects/{handle}/experiments/{experiment}/pins/{cardId}

A rule can split its runs between the variants of an experiment. The bridge draws a candidate variant for the card, and calls this endpoint before it starts the worker. The server pins one variant to each card of each experiment, so the later runs of a card keep the variant of its first run.

The handle follows the rules of the run state report. cardId is the uuid of the card, and experiment is the experiment name. The endpoint needs a token with the agent scope.

{
"candidate": "sonnet",
"variants": ["opus", "sonnet"],
"weights": [3, 1],
"metrics": ["merge-rate", "cost"]
}
FieldRule
variantsrequired. The variants the rule offers now, as a list of 1 to 32 unique names. Each name matches ^[a-z0-9][a-z0-9_-]{0,63}$
candidaterequired. The variant the bridge drew for the card. It must be one of variants
weightsoptional. The weight of each variant, in the order of variants. Each weight is an integer from 1 to 1000000
metricsoptional. The metric keys the Comparison tab of the experiment shows, in this order, as a list of 1 to 16 unique keys. Each key matches ^[a-z][a-z0-9:-]{0,63}$

The server keeps the latest valid weights of each experiment in a project. It ignores a weights list that breaks its rule, and still resolves the pin. A fault in weights never changes the answer, and never refuses the request.

The server stores the valid metrics with the weights, so a request with no valid weights stores no metrics. A request with valid weights and no valid metrics resets the list, and the Comparison tab then shows the default metrics. The server keeps a key that it does not know, and the page shows a note for it. A fault in metrics never refuses the request.

The server answers the variant the card runs with:

  • A card with no pin takes the candidate, and the server stores it as the pin.
  • A card whose pinned variant is still in variants keeps its pin, and the server ignores the candidate.
  • A card whose pinned variant is no longer in variants takes the candidate. The answer names the old variant in switchedFrom.
{"variant": "sonnet", "switchedFrom": "opus"}

switchedFrom is null when the pin did not move. Every call refreshes the pin, so the pins of active cards outlive the retention window. Two first calls for one card get the same answer, because the first pin stays.

StatusBodyWhen
200{"variant":"sonnet","switchedFrom":null}the server resolved the variant of the card
400a problem objectthe body is not valid JSON
415a problem objectthe Content-Type is not application/json
401the request carries no token
403{"error":"insufficient_scope"}the token carries another scope, such as site-review
404{"error":"project_not_found"}the user has no project with that handle, and another user’s project counts as none
404agent push is switched off on the instance, or the server has no such endpoint
422{"error":"invalid_card_id"}cardId is not a uuid
422{"error":"invalid_experiment"}experiment breaks the name pattern
422{"error":"invalid_variants"}the body is empty, variants breaks its rule, or candidate is not one of variants
429the token went over the rate limit. See Rate limit

Every refusal the endpoint makes carries an error code. The 400 and the 415 come from the framework before the endpoint runs, and carry none. The bridge reads a 404 with no error code as a server that has no pin endpoint.

The bridge waits 10 seconds for the answer. On any failure, such as a timeout, a network error or a status other than 200, it runs the candidate. It then logs experiment_pin_failed. After a timeout, the server can still hold a pin for the card, and the next run of the card then takes that pin.

Timed out and lost

A run stays open until its bridge reports how it ended. A bridge that dies cannot send that report, so the server closes the run itself.

A scheduled task runs each minute and marks each open run timed-out when its bridge is quiet. A bridge is quiet when its last heartbeat is older than three heartbeat intervals. The interval is the bridge.heartbeat_interval_seconds flag or the default of 60 seconds, whichever is longer. A lowered flag therefore never shortens the wait. A bridge that sent no heartbeat counts as quiet once the first report of the run is that old. One pass times out at most 500 runs, and the next pass takes the rest. The task skips an interactive run, because no bridge holds it.

timed-out is a guess. A report from the bridge replaces it, and so does a run inventory that names the run. lost is a fact: the bridge reconnected, and it no longer holds the run. Only a report that closes the run replaces lost.

app.bridge.run_timeout_schedule in config/services.yaml is the cron expression of the task. app:time-out-worker-runs runs the same pass by hand. See Console commands.

Send Accept: application/json to get a 422 body as JSON, on any endpoint of this page.

Controls in the web UI

The project owner can stop, resume, run again and pause from the web UI. The pages and their labels are in Stop, resume and cancel. These routes serve them. Each route takes the session cookie and a CSRF token, and needs the permission to manage the project. An agent token does not open them.

RouteWhat it does
POST /projects/{id}/worker-runs/{runId}/stopasks the bridge to stop the run. It holds nothing
POST /projects/{id}/worker-runs/{runId}/resumeasks the bridge to resume the session of the run. Loupe refuses it while the agents on the card are paused
POST /projects/{id}/worker-runs/{runId}/rerunasks the bridge to run the command of a failed command run again, as a new run
POST /projects/{id}/worker-runs/{runId}/cancel-commandwithdraws the request that waits on the run
POST /projects/{id}/worker-runs/card/{cardId}/pausepauses the agents on the card, and writes board.card_held
POST /projects/{id}/worker-runs/card/{cardId}/releaselets the agents on the card run again, and writes board.card_released
POST /projects/{id}/agents/{bridgeId}/pauseasks the bridge to take no new work
POST /projects/{id}/agents/{bridgeId}/unpauseends the pause

{runId} is the id the server gives the run, not the runId of the bridge. Each route answers 303. A run route goes back to the list of runs, filtered to the run, or to the runs section of the card when the request comes from that section. A bridge route goes back to the card of the bridge on the agents page.

StatusWhen
403the CSRF token is missing or wrong, or the person cannot manage the project
404the run is not in the project, or the bridge does not follow the project

A refused request still answers 303, and the page then shows the reason as an error:

ReasonWhen
No bridge runs this sessionthe run names no bridge
The bridge of this run is no longer connected to your accountthe account holds no row for the bridge. A pause gives the same refusal
This run already has a command that waits for its bridgea request waits on the run
You cannot control an interactive session from herethe run is interactive
This run has no session to resumea resume of a run with no sessionId
Only an ended run can resumea resume of an open run, or of a run that ended as succeeded, waiting-on-forge, dropped, replaced, skipped, not-started or closed
Only a queued or running run can stopa stop of a run that is not queued, resumed or running
Only a command run can run againa rerun of a run that is not a command run
Only a failed, timed-out or lost command run can run againa rerun of a command run in any other state
Update the bridge to control its runsthe bridge does not report the commands capability, or a rerun when it does not report the rerun-command capability
This run has no command that waitsa cancel with no request that waits
Update the bridge to pause ita pause of a bridge that does not report the commands capability. An unpause is always accepted

A queued run of a bridge that reports a pause shows Waiting: bridge paused in the web UI. The run keeps the state queued.

Feature flag and rate limit

The endpoints on this page need the agent.push.enabled feature flag, as GET /api/events does. A bridge reaches a worker only through the event stream, so an instance with push off can produce no run to report, and each endpoint answers 404 there.

Rate limit

The endpoints on this page share one limit, agent_worker_runs, of 240 requests in one minute for each token. A run sends about four state reports, and one tool call report for each 500 calls, with a minimum of one. A run in an experiment adds one pin call. The limit lets a bridge drain a full queue of 256 reports before its retries give up.

What a missing record means

A missing record means “unknown”, never “the worker did not run”. The bridge holds its outbound queue in memory. A bridge stopped with Ctrl-C or SIGTERM gives each report it still holds one last attempt, in a short window. A bridge that dies without warning loses what is in flight for good. Read the list of runs as what the server was told, not as a complete history.

A run whose closing report never arrives stays open until the server closes it. The timeout task marks it timed-out, or the next run inventory of its bridge marks it lost.

The output is whatever the agent printed. It may carry file contents, paths or anything else the agent chose to say, and anyone who can view the project can read it.

Retention

The server keeps a run record for 180 days, counted from when its first report arrived. An hourly sweep at minute 20 deletes the rest, with their history.

The bridge.run_retention_days feature flag sets the window, and you change it at /admin/feature-flags. An instance installed before the flag existed takes the value from app.bridge.default_run_retention_days in config/services.yaml, which is 180. A window below 1 day reads as 1 day, so a typed zero cannot take the whole history.

app:purge-worker-runs runs the same sweep by hand. See Console commands.

The sweep keeps the usage of a run it deletes. The usage row loses the link to its run, and keeps its project, its card, its work kind and its source, so the spend of a card outlives the run records. The sweep deletes the tool calls of a run with the run. The metrics row of the run keeps their counts and their timing.

The sweep cuts on the server’s arrival time rather than on the bridge clock. A bridge with a wrong clock would otherwise stamp a run outside the window and lose it on the next sweep.

The same sweep deletes each experiment pin whose last resolve is older than the run retention window. The next run of that card then picks a variant again. Every resolve refreshes a pin, so the sweep takes only the pins of idle cards.

The same sweep also deletes each host sample whose sampledAt is older than the window.

Deleting a project deletes its run records, its tool calls, its usage, its run facts, its experiment pins, its experiment weights and its card holds with it. Deleting an account deletes the same data of every project it owned, and removes the account’s name from a hold it placed in another project. The account’s data export holds each run in worker_runs.json, with its state, its history, its usage source, its worker pool, its experiment, variant, requestedModel and switchedFrom, its harness, account, model and harnessSessionId, its workRequestId, workKind and ruleId, and its toolTimeMs, idleGapMs and peakContextTokens. It holds every usage row in worker_run_usage.json. It holds every tool call in worker_run_tool_calls.json, with its project, the runKey of its run, and the fields of the tool call report. It holds the time of each run in each bucket in worker_run_bucket_times.json, with its project, the runKey of its run, the bucket and the ms. It holds the fact row of each run in worker_run_facts.json, with its outcome, its duration, its cost, its token sums and the run metrics of its timing, its peak context and its host. A fact row stays after the retention sweep deletes its run. It holds every experiment pin in experiment_pins.json, with its project, its card, its experiment, its variant, and when the pin was created and last resolved. It holds the latest weights of each experiment in experiment_definitions.json, with its project, its experiment, its weights as a list of name and weight objects, its metrics as a list of keys or null, and when the bridge sent them. It holds every card hold in bridge_card_holds.json, with its project, its card, the run it stopped and when it began. The run is null after the retention sweep deletes it.