Skip to content

Read one background job.

GET
/v1/admin/jobs/{job_id}
curl --request GET \
--url http://localhost:8080/v1/admin/jobs/example \
--header 'Authorization: Bearer <token>'
job_id
required
string

Jobs framework (2026-08-12): a job’s id. NOT a uuid — it is the code-owned dotted identifier from the registry (“artwork.sweep”, “template.warmup”), which is also the jobs primary key, so the id in a URL is the same string an operator reads in a log line. That is deliberate: a job’s identity is authored in code and reconciled at boot, never minted by a database default.

OK.

Media typeapplication/json

One registered background job. The row is split by OWNERSHIP: identity (name, description, plane, scope, managed) is code-owned and reconciled at every boot, and the schedule is admin-owned and never overwritten by that reconcile - a boot that clobbered an operator’s 02:00-06:00 window because a developer edited a literal would make the whole surface untrustworthy. THREE SHAPES, DISCRIMINATED BY managed AND scope. A managed instance-scoped job carries top-level running / next_run_at / last_run / consecutive_failures. A managed host-scoped job carries targets INSTEAD, one entry per host. An unmanaged job of either scope carries NEITHER, plus unmanaged_note. A run-derived field is OMITTED rather than sent as null when it does not apply or is not known (Go omitempty on a nil pointer); a client must read absent as null.

object
id
required

The code-owned dotted identifier, e.g. “artwork.sweep”. Also the URL path segment and the id in every log line.

string
name
required

Operator-facing display name, code-owned.

string
description
required

What this job does, code-owned. For an UNMANAGED job it is also the text of unmanaged_note.

string
plane
required

WHERE the job’s work executes. ‘control’ runs in the control-plane process; ‘agent’ is claimed over the /v1/agent/jobs/* pull channel and executed by a node agent. It is not a hint - it decides which of the two dispatch paths ever sees the run.

string
Allowed values: control agent
scope
required

WHAT a run is about. ‘instance’ = one run for the whole deployment (its job_runs rows carry no host_id). ‘host’ = one INDEPENDENT run per host, which is why a host-scoped job reports targets[] instead of top-level run state: “last run” is not a single fact about the job, it is a fact about the job ON A HOST.

string
Allowed values: instance host
managed
required

False = “listed but not adopted”: background work that exists in code and runs on a hard-coded timer, shown here so an operator can SEE it, with no schedule, no run history and no Run-now. The list of unmanaged rows is therefore also the adoption backlog. Omitting them would reproduce the exact problem the Jobs page was built to fix - a page of six rows next to twelve invisible goroutines.

boolean
enabled
required

The operator’s kill switch, and it MEANS it: a disabled job never runs, not even from Run now (409 job_disabled).

boolean
schedule
required

The ADMIN-OWNED half of a job (the code-owned half is its identity), resolved exactly as the dispatcher resolves it - one implementation of “which source won”, so the viewer and the scheduler cannot disagree about when a job will next run.

object
kind
required

‘interval’ fires every interval_secs measured from the END of the previous run (timer-reset-after-pass, so overlap is impossible by construction rather than unlikely). ‘event’ never fires on a clock - a run row is created by an explicit trigger - which is how event-driven work is represented WITHOUT pretending it is periodic, while still showing last-run and result. ‘manual’ only ever runs from an admin trigger.

string
Allowed values: interval event manual
interval_secs
required

Null for a non-interval schedule. Floor of 60 (a CHECK in the migration and a guard in the handler): a job that wants to run more often than once a minute is not a background job. When locked is true this is the ENVIRONMENT’s value, not the stored one.

integer | null
window_start
required

HH:MM:SS in timezone, or null for ‘any time’. Paired with window_end by a CHECK - both set or both null.

string | null
window_end
required

HH:MM:SS in timezone, or null. A window that WRAPS MIDNIGHT (22:00 -> 04:00) is legal and normal. The window governs STARTING a run and never stopping one: a run in flight when the window closes is not killed, because killing a half-finished dedupe pass or a half-built template is worse than overrunning by minutes.

string | null
window_days
required

0 = Sunday .. 6 = Saturday (Go’s time.Weekday). EMPTY MEANS EVERY DAY, and empty is what an unconstrained job reports - never null. A day constrains the instant the window OPENS, so a wrapping window on {5} runs Friday 22:00 -> Saturday 04:00.

Array<integer>
timezone
required

IANA zone name; windows are evaluated in it. Intervals are NOT: an interval is a duration in absolute time and has no opinion about the clock on the wall, so a DST transition can shift when a windowed run lands but can never lengthen an interval.

string
locked
required

True when an environment variable is authoritative over this job’s interval. The env override stays the winner (the pre-existing documented behaviour of knobs like QUASAR_LIBRARY_SCAN_INTERVAL); the API says so rather than silently accepting an edit the environment will overrule - a PATCH of interval_secs on a locked job is 409 schedule_locked.

boolean
locked_by
required

The environment variable that is in force, e.g. “QUASAR_LIBRARY_SCAN_INTERVAL”. Null when not locked.

string | null
running

MANAGED scope=instance only: an open run is currently in flight. Absent for a host-scoped or unmanaged job.

boolean
next_run_at

MANAGED scope=instance only: the open PENDING run’s scheduled_for. A pending row IS the next run - there is no denormalized next-run column to drift out of sync.

string | null format: date-time
last_run
One of:

One record of one run - the shape that appears as last_run and as an item of the run-history page.

object
id
required
string format: uuid
host_id
required

Null for an instance-scoped job’s run; the target host for a host-scoped one.

string | null format: uuid
state
required

The run lifecycle: pending -> running -> one terminal state. ‘deferred’ means the job’s OWN gate refused (a host with live sessions, say) - an outcome, not an error, and the dispatcher schedules the retry on a persisted backoff ladder. ‘skipped’ means there was nothing to do. ‘aborted’ is the claim-timeout reaper’s verdict on a run nobody reported; it is the one state an agent may never report about itself.

string
Allowed values: pending running succeeded failed deferred skipped aborted
trigger
required

What created the run row: the schedule, an admin’s Run now, or an explicit event.

string
Allowed values: schedule manual event
started_at
required

Null while the run is still pending (it has not started).

string | null format: date-time
finished_at
required
string | null format: date-time
duration_ms
required

Null until the run is finished; derived from started_at/finished_at rather than stored.

integer | null format: int64
summary
required

The runner’s own per-job result blob - what it actually did, in its own vocabulary ({“apps_considered”: 412, “artwork_resolved”: 3}). OPAQUE TO THE FRAMEWORK, which never interprets it, and {} rather than null when a run reported nothing. Bounded at 4096 bytes by a CHECK; a summary that blows the bound fails the REPORT, never the run.

object
key
additional properties
any
error
required

The failure text for a failed run; null otherwise.

string | null
consecutive_failures

MANAGED scope=instance only: terminal failed runs since the last non-failed terminal outcome. DERIVED from history rather than stored, so it cannot drift from the rows it describes.

integer
targets

MANAGED scope=host only: per-host run state, one entry per known host. Absent for an instance-scoped or unmanaged job.

Array<object>

One host’s independent run state for a MANAGED host-scoped job. Computed by the same code path as the instance-scoped top-level fields, so the two shapes cannot drift apart.

object
host_id
required
string format: uuid
node_name
required

Hosts.node_name - display only. An unresolved name renders as “” rather than failing the list: a stale host row must not break the whole page.

string
running
required
boolean
next_run_at
required

The open PENDING run’s scheduled_for. Null when nothing is queued for this host.

string | null format: date-time
last_run
required
One of:

One record of one run - the shape that appears as last_run and as an item of the run-history page.

object
id
required
string format: uuid
host_id
required

Null for an instance-scoped job’s run; the target host for a host-scoped one.

string | null format: uuid
state
required

The run lifecycle: pending -> running -> one terminal state. ‘deferred’ means the job’s OWN gate refused (a host with live sessions, say) - an outcome, not an error, and the dispatcher schedules the retry on a persisted backoff ladder. ‘skipped’ means there was nothing to do. ‘aborted’ is the claim-timeout reaper’s verdict on a run nobody reported; it is the one state an agent may never report about itself.

string
Allowed values: pending running succeeded failed deferred skipped aborted
trigger
required

What created the run row: the schedule, an admin’s Run now, or an explicit event.

string
Allowed values: schedule manual event
started_at
required

Null while the run is still pending (it has not started).

string | null format: date-time
finished_at
required
string | null format: date-time
duration_ms
required

Null until the run is finished; derived from started_at/finished_at rather than stored.

integer | null format: int64
summary
required

The runner’s own per-job result blob - what it actually did, in its own vocabulary ({“apps_considered”: 412, “artwork_resolved”: 3}). OPAQUE TO THE FRAMEWORK, which never interprets it, and {} rather than null when a run reported nothing. Bounded at 4096 bytes by a CHECK; a summary that blows the bound fails the REPORT, never the run.

object
key
additional properties
any
error
required

The failure text for a failed run; null otherwise.

string | null
history_limit
required

How many run rows this job retains (1..500).

integer
>= 1 <= 500
unmanaged_note

UNMANAGED jobs only: a human sentence naming the file that hard-codes this work and saying no history is recorded. Authored in ONE place (the registry Definition’s description) rather than duplicated as a second field.

string
Example
{
"plane": "control",
"scope": "instance",
"schedule": {
"kind": "interval"
},
"last_run": {
"state": "pending",
"trigger": "schedule"
},
"targets": [
{
"last_run": {
"state": "pending",
"trigger": "schedule"
}
}
]
}

Missing/invalid/expired/revoked token.

Media typeapplication/json
object
error
required
object
code
required

E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.

string
message
required
string
live_sessions

Present on restart_required.

integer
session_id

Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.

string format: uuid
derived_tiles

Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.

Array<object>
object
id
required
string format: uuid
name
required
string
Examplegenerated
{
"error": {
"code": "example",
"message": "example",
"live_sessions": 1,
"session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"derived_tiles": [
{
"id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"name": "example"
}
]
}
}

Authenticated but insufficient role / not the owner (precedes resource lookup).

Media typeapplication/json
object
error
required
object
code
required

E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.

string
message
required
string
live_sessions

Present on restart_required.

integer
session_id

Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.

string format: uuid
derived_tiles

Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.

Array<object>
object
id
required
string format: uuid
name
required
string
Examplegenerated
{
"error": {
"code": "example",
"message": "example",
"live_sessions": 1,
"session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"derived_tiles": [
{
"id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"name": "example"
}
]
}
}

No such resource.

Media typeapplication/json
object
error
required
object
code
required

E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.

string
message
required
string
live_sessions

Present on restart_required.

integer
session_id

Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.

string format: uuid
derived_tiles

Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.

Array<object>
object
id
required
string format: uuid
name
required
string
Examplegenerated
{
"error": {
"code": "example",
"message": "example",
"live_sessions": 1,
"session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"derived_tiles": [
{
"id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0",
"name": "example"
}
]
}
}