HTTP API
Every endpoint the server exposes. The API is served under the /v1 base path. The web UI at
/ui/, along with /healthz, /readyz, /metrics, /.well-known/loomseal.json, the OpenID
Connect and SAML sign-in routes, the webhook /hooks path, and the /relay worker path, is
unversioned. The root redirects to the UI.
/.well-known/loomseal.json is deliberately public and unauthenticated: it publishes this install's
signing key and identifiers so a relying party can pin the key from a channel independent of any
bundle it is handed.
Licensed endpoints
Almost every endpoint below runs on Community, which needs no license. Three answer 403 without one,
with a message naming the feature rather than failing in some subtler way:
| Endpoint | Tier | Note |
|---|---|---|
GET /v1/audit/register |
Team | The period change register, covering the last 90 days unless from and to name another period, each a date or an RFC 3339 timestamp. Per-run dossiers, receipts, bundles, and GET /v1/audit/verify are free. |
POST /v1/drift/reconcile |
Team | One-click reconcile. Drift detection is free. |
POST and PUT /v1/policies |
Team, past the free set | Deny rules, risk floors, actor scoping, and distinct-approver separation of duties. One require-approval policy is Community, Pro holds five, Team is uncapped. |
Two more are enforced somewhere other than the request:
/relayis served whatever the license. The gate is on the other end:switchtender worker --serverrefuses to start without Team, and so does a worker sharing the database: every worker process is gated.- The directory sign-in routes need Pro, enforced at startup rather than per request. Configuring any of OIDC, SAML, LDAP, or JWT without a license refuses the server at startup, so those routes are either licensed or absent.
| Method | Path | What |
|---|---|---|
| POST | /v1/runs |
Submit a run. shards of two or more splits it. |
| GET | /v1/runs |
Run history, newest first. Pages with limit (default 200, maximum 1000) and offset; the response carries has_more and next_offset. Filters: status, tool, order, task, after, before, and q for a text search over the run. |
| GET | /v1/runs/{id} |
One run. |
| POST | /v1/runs/{id}/cancel |
Cancel a pending or running run. |
| POST | /v1/runs/{id}/retry |
New split from only the failed shards of a finished one. |
| POST | /v1/runs/{id}/relaunch-failed |
Re-run only the hosts a finished run left failed or unreachable. |
| POST | /v1/runs/{id}/approve |
Release a run held for approval so it runs. |
| POST | /v1/runs/{id}/reject |
Deny a run held for approval. |
| GET | /v1/runs/{id}/shards |
Shard runs of a split. |
| GET | /v1/runs/{id}/steps |
Step runs of a pipeline. |
| GET | /v1/runs/{id}/logs |
Captured output as plain text, streamed. ?tail=<bytes> returns only the end, capped at 4 MiB; when anything was dropped the response carries Switchtender-Log-Truncated: 1 and Switchtender-Log-Omitted-Bytes. |
| GET | /v1/runs/{id}/evidence |
Self-contained HTML evidence document for one run. ?format=json returns the same content as JSON. |
| GET | /v1/runs/{id}/receipt |
Signed LoomSeal receipt proving what this run did. ?sparse discloses only this run's own entries, each proved to belong to the whole chain; ?from=<size> adds a consistency proof that the log only appended since that size. The response carries the signing key's id in a Switchtender-Key-Id header. |
| POST | /v1/runs/{id}/rerun |
Submit a fresh run with this run's execution settings. |
| POST | /v1/runs/{id}/stream-ticket |
Mint a short-lived, single-use ticket for opening this run's event stream. |
| GET | /v1/runs/{id}/events |
Structured events as JSON. ?after=<seq> and ?limit page them, and the response carries next_after to continue. ?download=1 streams the same events as newline-delimited JSON with a filename attachment. |
| GET | /v1/runs/{id}/compare |
What changed against a baseline run: host verdicts, task timing, duration. with= names the baseline or prev for the previous run of the same source. |
| GET | /v1/runs/{id}/stream |
Live events and log over Server-Sent Events. Opened with ?ticket= from the endpoint above, since EventSource cannot set a header. |
| POST | /v1/runs/{id}/explain |
Advisory AI explanation of a run, when a provider is configured. |
| POST | /v1/ai/draft |
Advisory AI draft of a bash, python, powershell, or go step script from a description. Operator role. |
| POST | /v1/ai/ask |
Advisory AI answer to a fleet question, from run, health, and drift metadata. Rate limited. |
| POST | /v1/ai/propose-run |
Turn a plain-language request into a run proposal, validated and held for approval. Operator role. |
| POST | /v1/drift/reconcile |
Build a reconcile proposal for a drifted host, held for approval. Operator role. |
| POST | /v1/pipelines |
Submit ordered playbook steps as one pipeline. |
| POST | /v1/schedules |
Cron schedule for a run, split, pipeline, or template. The response records created_by. |
| GET | /v1/schedules |
List schedules. |
| GET | /v1/schedules/preview |
Next fire times for a cron expression and timezone, without saving anything. |
| GET | /v1/schedules/{id} |
One schedule. |
| PUT | /v1/schedules/{id} |
Update a schedule. |
| DELETE | /v1/schedules/{id} |
Delete a schedule. |
| GET | /v1/fleet |
Hosts ranked by failures over recent runs, flaky flags. |
| GET | /v1/hosts/{host}/runs |
One host's recent per-run outcomes. |
| GET | /v1/hosts/{host}/facts |
The most recent Ansible facts gathered for one host. |
| GET | /v1/estate |
The estate as it stood at an instant. at is RFC 3339 and defaults to now, so the current estate is the same query with no argument. Each host carries the newest facts gathered at or before that instant, so a host holds its last observed state until something newer was seen, and a host first gathered afterward is absent rather than invented. withheld counts hosts left out because the caller may not read the run that gathered them. Retention deleting that run does not withhold it: the purge retains what decided readability, so a reading stays readable to whoever could always read it. horizon is the oldest retained reading and before_history reports that the instant predates it, which separates an estate that was empty from records that do not reach that far. |
| GET | /v1/estate/diff |
What changed between two instants. from is required, to defaults to now. Each host is added, changed, or unobserved, and a changed host names the fact keys that differ with the value at each end. unobserved means nothing gathered that host inside the window, so its state is carried forward rather than confirmed: not known to have changed, and not known not to have. unchanged counts hosts gathered in the window and found identical, which is a different and stronger statement. |
| GET | /v1/changes |
Every change the caller can see, newest first, each summarized without its member runs. Built by scanning recent runs for the label, so scanned reports how many were read and partial reports that the scan was capped: a change whose runs are all older than that is not listed. The period change register answers a date range exhaustively. |
| GET | /v1/changes/{change} |
One change: every run carrying the change label with that value, as one thing. Carries the span, who acted, and an outcome derived from the member runs rather than declared, so it cannot disagree with what happened. A change is what an auditor asks about; a run is what an executor produces. withheld counts members the caller may not read, so a partial change is never mistaken for the whole one. |
| GET | /v1/tasks |
Per-task duration trends over recent runs. |
| GET | /v1/drift |
Resources drifting from desired state, from dry runs. |
| POST | /v1/projects |
Register a git project. Runs record their commit. |
| GET | /v1/projects |
List projects. |
| PUT | /v1/projects/{id} |
Update a project. |
| DELETE | /v1/projects/{id} |
Delete a project. 409 while a template or source uses it. |
| GET | /v1/projects/{id}/files |
Browse the project checkout's tree. |
| GET | /v1/projects/{id}/file |
Read one file from the project checkout. ?path= within the repo. |
| POST | /v1/templates |
Save a launch preset. |
| GET | /v1/templates |
List templates. |
| POST | /v1/templates/{id}/launch |
Launch a template, answering its survey and choosing selectable credentials if it has them. |
| PUT | /v1/templates/{id} |
Update a template. |
| DELETE | /v1/templates/{id} |
Delete a template. |
| POST | /v1/triggers |
Create a webhook trigger, returns a signing secret once. |
| PUT | /v1/triggers/{id} |
Rename a trigger or toggle signature enforcement. |
| POST | /v1/triggers/{id}/rotate-secret |
Rotate the signing secret, shown once. |
| GET | /v1/triggers |
List webhook triggers. |
| DELETE | /v1/triggers/{id} |
Delete a trigger, revoking its webhook. |
| POST | /hooks/{token} |
Fire a trigger from a git push. A required HMAC signature is checked first. |
| POST | /v1/credentials |
Store a credential, encrypted at rest. Fourteen built-in kinds, or a custom type via type_id and fields. Non-secret settings ride beside the secret and return from the API. |
| GET | /v1/credentials |
List credentials, secrets never included. |
| POST | /v1/credential-types |
Define a custom credential type: fields and how they inject. Admin only. |
| GET | /v1/credential-types |
List custom credential types. Admin only. |
| GET | /v1/credential-types/{id} |
One custom credential type. Admin only. |
| PUT | /v1/credential-types/{id} |
Replace a custom credential type. Admin only. |
| DELETE | /v1/credential-types/{id} |
Delete a custom credential type. Admin only. |
| PUT | /v1/credentials/{id} |
Update a credential. |
| DELETE | /v1/credentials/{id} |
Delete a credential. 409 while an object still uses it. |
| POST | /v1/auth/login |
Sign in with username and password, returns a token. |
| POST | /v1/auth/check |
Verify an API token. |
| GET | /v1/auth/me |
Who the server resolved the caller to be. |
| POST | /v1/auth/logout |
End the caller's own session, revoking its token. |
| POST | /v1/tokens |
Mint a token bound to an account. Returns it once. |
| GET | /v1/tokens |
List tokens without secrets. Admin only. |
| DELETE | /v1/tokens/{id} |
Revoke a token everywhere at once. Admin only. |
| GET | /auth/oidc/login |
Start the OpenID Connect sign-in handshake. |
| GET | /auth/oidc/callback |
Complete the OIDC handshake and issue a token. |
| GET | /auth/saml/login |
Start the SAML sign-in handshake. |
| POST | /auth/saml/acs |
Consume the IdP assertion and issue a token. |
| GET | /auth/saml/metadata |
Service provider metadata for IdP registration. |
| POST | /v1/users |
Create an account with a role and an optional profile. |
| GET | /v1/users |
List accounts with their profiles, admin only. |
| PUT | /v1/users/{id} |
Update an account's role, password, or profile. |
| DELETE | /v1/users/{id} |
Delete an account. Its tokens stop working. |
| POST | /v1/teams |
Create a team of users. |
| GET | /v1/teams |
List teams. |
| DELETE | /v1/teams/{id} |
Delete a team and its memberships. |
| POST | /v1/teams/{id}/members |
Add a user to a team. |
| GET | /v1/teams/{id}/members |
List a team's members. |
| DELETE | /v1/teams/{id}/members/{userID} |
Remove a user from a team. |
| POST | /v1/orgs |
Create an organization. |
| GET | /v1/orgs |
List organizations. |
| DELETE | /v1/orgs/{id} |
Delete an organization and its memberships. |
| POST | /v1/orgs/{id}/members |
Add a user to an organization with an organization role. Organization admin grants manage over that organization's projects, templates, inventories and credentials, bounded by the account's global role: on a viewer account it confers use, not manage. See concepts. |
| GET | /v1/orgs/{id}/members |
List an organization's members and their roles. |
| DELETE | /v1/orgs/{id}/members/{userID} |
Remove a user from an organization. |
| POST | /v1/grants |
Grant a user or team read, use, or manage on an object: a project, template, inventory, or credential id, or a worker queue as queue:<name>. |
| GET | /v1/grants |
List access grants. |
| DELETE | /v1/grants/{id} |
Delete an access grant. |
| GET | /v1/workers |
The executor fleet with lease freshness. |
| POST | /v1/inventory-sources |
Register a dynamic inventory source. |
| GET | /v1/inventory-sources |
List inventory sources. |
| POST | /v1/inventory-sources/{id}/refresh |
Refresh a source into its inventory now. |
| PUT | /v1/inventory-sources/{id} |
Update an inventory source. |
| DELETE | /v1/inventory-sources/{id} |
Delete an inventory source. |
| POST | /v1/inventories |
Store an inventory. Runs reference it by id anywhere. |
| GET | /v1/inventories |
List stored inventories. |
| PUT | /v1/inventories/{id} |
Update a stored inventory. |
| DELETE | /v1/inventories/{id} |
Delete a stored inventory. |
| POST | /v1/policies |
Create a policy that holds or denies matching runs. |
| GET | /v1/policies |
List approval policies. |
| PUT | /v1/policies/{id} |
Update an approval policy. |
| DELETE | /v1/policies/{id} |
Delete an approval policy. |
| POST | /v1/import/{format} |
Import an AWX, Semaphore, Chef, Puppet, Rundeck, or Jenkins export. Format is awx, semaphore, chef, puppet, rundeck, or jenkins; any other format is refused. Chef and Puppet bring a fleet as an inventory rather than job definitions, and take no ?inventory=. Rundeck and Jenkins take ?inventory= to say which hosts their jobs target, since neither brings an inventory. Two formats accept a zip: the Jenkins body is one config.xml or a zip of a jobs directory, and the Rundeck body is a job export or a project archive, each told apart by content. The body is capped at 25 MiB and a larger one is refused with 413, which is lower than the CLI, where a project archive is bounded only by the archive reader's own limits. Previews by default; ?apply=true writes the plan. A crontab imports from the CLI only, with switchtender import cron. Which objects each format carries is in what each source brings over. |
| GET | /v1/audit |
A page of the mutation trail, admin only. ?limit= up to 1000, default 100; has_more reports whether older entries remain. |
| GET | /v1/audit/register |
The change register as a self-contained HTML document, admin only. |
| GET | /v1/doctor |
Install health checks and their findings, admin only. |
| GET | /v1/audit/verify |
Verify the audit hash chain is intact. |
| GET | /v1/audit/bundle |
The audit chain as a signed LoomSeal bundle, verifiable offline or on the /verify page. |
| GET | /metrics |
Prometheus series: run, fleet, queue-depth, and worker gauges, plus a run-duration histogram. |
| GET | /healthz |
Liveness. |
| GET | /readyz |
Readiness: 200 once the store answers, 503 while it does not. |
A streamed export whose status line has already been sent cannot report a later failure with a
status code. The run event NDJSON download and the run log download therefore end with a
{"export_incomplete":true,"reason":"..."} line when they stop early, so a short file is never
mistaken for a whole one.
When the chain itself refuses a bundle
GET /v1/audit/bundle recomputes the whole chain and holds it against every anchor recorded over it
before any window is applied. A chain this server checked and rejected is a finding, not a fault, so
it answers 409 rather than 500, and a caller can tell the two apart without reading prose. A
500 is left to a real fault here, such as a store that will not read, and a limit that is not a
count stays a 400.
{
"error": "entry 3 does not recompute (sequence 3)",
"reason": "chain_break",
"broke_at": 3,
"broke_seq": 3,
"count": 9
}
reason |
Meaning |
|---|---|
chain_break |
An entry does not recompute. broke_at is its one-based position and broke_seq its chain sequence, both zero when the entry carries no readable sequence, which is itself a shape tampering takes. |
anchor_unsatisfied |
Every entry recomputes, but the chain no longer satisfies an anchor recorded over it, which is how a missing tail shows up. anchor_problems names each one. |
chain_unbundlable |
The chain verifies but no bundle can be formed over it. |
The coordinates are the same ones GET /v1/audit/verify reports, so the two answers agree. A
windowed request is refused for a break anywhere in the chain, not only inside the window: a bundle
signed over a window sitting past a break would attest to entries this install cannot stand behind.
Opening a live stream
EventSource cannot set a header, so GET /v1/runs/{id}/stream takes a short-lived ticket in the
query string instead of a bearer token. Mint one over the ordinary authenticated route and open the
stream with it:
curl -X POST -H "Authorization: Bearer $ST_TOKEN" \
localhost:8080/v1/runs/run_abc/stream-ticket
# {"ticket":"...","expires_in":30}
A ticket opens that one run, works once, and expires in thirty seconds. The reason is that a URL is not private: a reverse proxy logs the full request line by default, so a session token in the query string reaches every access log downstream. A ticket in the same place is worth almost nothing.
An install running open, with no tokens, needs no ticket and the plain path works.
Account profiles
An account carries an optional profile alongside its role: full_name, email, phone, title,
links, and notes. All of them are optional, so an account created by the CLI or provisioned over
single sign-on stays valid with none of them set. title is descriptive and grants nothing; role
alone decides what an account may do.
curl -X PUT https://switchtender.example.com/v1/users/user_9f2c \
-H "Authorization: Bearer $ST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"username": "ada",
"role": "operator",
"full_name": "Ada Lovelace",
"email": "ada@example.com",
"phone": "+1 555 0100",
"title": "Platform Engineer",
"links": ["https://wiki.example.com/people/ada"],
"notes": "review each quarter"
}'
The profile is replaced wholesale on update, so send the profile you want to end up with rather than only the parts that changed. An omitted field clears.
A profile is personal data and is treated as such. Only an admin may read it. /v1/users requires
the admin role and is not delegable by a manage grant. The values are never written to the logs, and
a rejection names the offending field without echoing it. Each single-line field is capped at 320
characters, notes at 2000, and an account may carry at most eight links. A link must be an http
or https address; any other scheme is refused, because the admin page renders links as anchors.
Template run timeout
A template may cap how long its launches are allowed to execute with timeout, a whole number of
seconds. It is accepted on create and update, and every launch of the template carries it onto the
run, whether the launch came from the API, a schedule, or a webhook trigger.
curl -X POST https://switchtender.example.com/v1/templates \
-H "Authorization: Bearer $ST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "nightly database backup",
"playbook": "plays/backup.yml",
"timeout": 5400
}'
Zero, or the field omitted, leaves launches on the server default set by --run-timeout, so a
template saved before this field existed is unchanged. A run that exceeds its timeout is canceled
and finalized as failed. A launch cannot raise the cap; the template's value is what applies.
Naming what a run targets
Two fields name a target and they are not interchangeable.
inventory_id names a stored inventory, the kind the UI creates and the one almost every
caller wants. inventory is a path to an inventory file already on the server, for a run whose
inventory is managed outside this product.
Sending a stored inventory's name in inventory is read as a path. Ansible exits zero when a
host pattern matches nothing, so a run aimed at a path that does not exist is recorded as
succeeded having touched no host at all.
Ansible run controls
A run submission and a template both accept the Ansible controls that used to require a hand-built command. They ride onto a run the same way from the API, a schedule, or a webhook trigger, and a retry keeps them.
| Field | Type | What it does |
|---|---|---|
limit |
string | Narrows the run to the hosts matching this pattern. Becomes --limit. Empty targets the whole inventory. |
tags |
list of strings | Runs only the plays and tasks carrying one of these tags. Becomes --tags. |
skip_tags |
list of strings | Skips the plays and tasks carrying one of these tags. Becomes --skip-tags. |
forks |
integer | How many hosts Ansible addresses at once. Zero leaves the Ansible default. Becomes --forks. |
verbosity |
integer 0 to 4 | Raises Ansible logging. One through four becomes -v through -vvvv; a higher number is clamped to four. |
diff_mode |
boolean | Shows the before and after of every changed file and template. Becomes --diff. |
curl -X POST https://switchtender.example.com/v1/runs \
-H "Authorization: Bearer $ST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"playbook": "plays/deploy.yml",
"inventory_id": "inv_3f9c1b7a2e04",
"limit": "canary01",
"tags": ["web", "config"],
"skip_tags": ["reboot"],
"forks": 25,
"verbosity": 2,
"diff_mode": true
}'
These apply to the Ansible tool. The other tools ignore them, so a Bash or Terraform template that carries one is unaffected.
A run submission also accepts extra_vars, an object of variables injected into the run. Ansible
receives them the way --extra-vars supplies them, and a plugin tool reads them as its input. A
template carries its own extra_vars and a launch may merge more over them.
Saved workflows
A template may carry steps, a pipeline graph, instead of a single tool. Such a
template is a saved workflow: every path that fires a template, a launch, a schedule, or a webhook
trigger, runs the graph as a pipeline, and the template's survey answers and extra vars reach every
step. A workflow template sets no top-level playbook, command, tool, shards, or Ansible
controls, since each step names its own; the graph is validated when the template is saved, so a
cycle or an unknown dependency is refused then rather than on every launch.
curl -X POST https://switchtender.example.com/v1/templates -H "Authorization: Bearer $ST_TOKEN" -H 'Content-Type: application/json' -d '{
"name": "build and ship",
"inventory_id": "inv_3f9c1b7a2e04",
"steps": [
{"name": "build", "tool": "bash", "command": "make release"},
{"name": "deploy", "playbook": "deploy.yml", "depends_on": ["build"]}
]
}'
List responses
Every list response is an envelope: the rows under a name, count for how many were returned, and
total for how many exist. They agree on any ordinary install.
The run list pages, because run history grows without bound: limit (default 200, maximum 1000) and
offset, with has_more and next_offset to continue. The configuration lists, users, tokens,
templates, schedules, triggers and organizations, return at most 1000 rows in one response. Past
that, total exceeds count and the response is the first 1000. An install with more configuration
than that should read it through the object endpoints rather than the list.
Survey field constraints
A template survey field accepts bounds beyond its type, checked at launch before any answer becomes
an extra var. A field also takes an optional help string shown beneath its prompt, and a
multiline type for a block of text such as a set of variables or a note.
| Field kind | Constraints |
|---|---|
int |
min and max bound the answer, inclusive. |
text, multiline |
min_length and max_length bound the length; pattern is a regular expression the whole answer must match. |
choice |
The answer must be one of choices. |
A launch that violates a constraint is refused with the field it failed, and no run is submitted.
The shape itself, which strict decoding refuses to guess at:
{
"survey": [
{"var": "release", "label": "Release tag", "type": "text",
"required": true, "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$",
"help": "The tag to deploy, such as v2.1.0"},
{"var": "batch", "label": "Hosts per batch", "type": "int",
"default": 5, "min": 1, "max": 50},
{"var": "environment", "label": "Environment", "type": "choice",
"required": true, "choices": ["staging", "production"]},
{"var": "notes", "label": "Change notes", "type": "multiline",
"max_length": 2000}
]
}
type is one of text, multiline, int, choice, or bool. var names the extra var the
answer becomes, and it is the only field besides type that every entry must carry. An unknown key
is refused rather than ignored, so a survey that almost parses is reported instead of silently
losing a field.
Per-template notifications
A template or a run submission may carry notifications, a list of targets that receive its
terminal state in addition to the server-wide channels. Each target names a kind and the field
that kind is addressed by, plus an optional on_failure that limits the target to failed runs.
| Kind | Required fields | What is sent |
|---|---|---|
webhook |
url |
The finished run as JSON, extra vars redacted. |
slack, mattermost, rocketchat |
url |
A message to the incoming webhook. |
discord, teams |
url |
A message or Adaptive Card to the webhook. |
ntfy |
url |
A notification to the topic, raised priority on failure. |
pagerduty |
key |
An incident trigger on the routing key, failed and interrupted runs only. |
grafana |
url, key |
An annotation to that instance's annotations API with that token. |
twilio |
to |
An SMS to that recipient through the server-held Twilio account. |
email |
to |
Mail to that comma-separated recipient list through the server SMTP transport. |
curl -X POST https://switchtender.example.com/v1/templates \
-H "Authorization: Bearer $ST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "prod deploy",
"playbook": "plays/deploy.yml",
"notifications": [
{"kind": "slack", "url": "https://hooks.slack.com/services/T0/B1/x"},
{"kind": "pagerduty", "key": "R0UTINGKEY", "on_failure": true},
{"kind": "email", "to": "oncall@example.com, lead@example.com"}
]
}'
A malformed target is refused at create or update with the field it lacks, not dropped at delivery. A Twilio or email target names only a recipient. The account credentials stay in server flags, so a template never carries them. On read, webhook URLs, PagerDuty routing keys, and Grafana tokens come back masked; an edit that echoes the mask back keeps the stored value.
Schedule timezone
A schedule reads its cron expression in the server's local time unless it carries a timezone, an
IANA name such as America/New_York or Europe/Berlin. With one set, 0 2 * * * fires at 02:00
in that zone and follows its daylight-saving shifts, so a nightly window stays put across the year.
The field is accepted on create and update and applies to the same expression the preview endpoint
renders.
curl -X POST https://switchtender.example.com/v1/schedules \
-H "Authorization: Bearer $ST_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"cron":"0 2 * * *","timezone":"America/New_York","template_id":"tpl_abc123"}'
Fleet view windows
/v1/fleet and /v1/tasks take a window, the number of recent runs per host or per task the
view considers, and /v1/hosts/{host}/runs takes a limit. All three default to 10. The window is
capped at 100 and the host history limit at 500; a larger value is answered with the cap, and the
response echoes the window it actually used. The caps exist because the per-host and per-task
summaries are kept when their runs are deleted, so on a long-lived fleet the tables hold a row for
every host of every run, and every row a window admits becomes an element of the answer.
The same tables are bounded by count rather than by age. --retain-history keeps the newest N
summaries for each host and each task and drops the rest, so a host's outcome history still
outlives its runs without the tables growing forever. N is never allowed below 500, the deepest
window these endpoints will answer, so trimmed history is history no request could have reached.
curl -s "https://switchtender.example.com/v1/fleet?window=30" \
-H "Authorization: Bearer $ST_TOKEN"
Relay endpoints
With --worker-token set, the server also serves the mesh relay under /relay: the execution
path an outbound worker started with --server uses instead of a database connection. Every call
presents the worker bearer token.
| Method | Path | What |
|---|---|---|
| GET | /relay/v1/policies |
Read the approval policies in force. |
| POST | /relay/v1/claim |
Lease the oldest pending run for the caller. |
| POST | /relay/v1/heartbeat |
Renew the lease on a run. |
| GET | /relay/v1/runs/{id} |
Fetch one run. |
| POST | /relay/v1/runs/{id}/save |
Save the run's state. |
| POST | /relay/v1/runs/{id}/log |
Append captured output. |
| POST | /relay/v1/runs/{id}/events |
Append structured events. |
| POST | /relay/v1/runs/{id}/propose-apply |
Report a plan's findings so the control node holds its apply. |
| POST | /relay/v1/runs/{id}/host-summary |
Save the run's per-host summaries. |
| POST | /relay/v1/runs/{id}/host-facts |
Save the facts the run gathered per host. |
| POST | /relay/v1/runs/{id}/task-summary |
Save the run's per-task summaries. |
Each report call is bounded twice. It presents the per-claim capability the claim response issued, so it can only write to the run this worker holds, and one call carries at most a few thousand items, so a worker cannot force an unbounded decode on the control node; the worker sends a wide run's evidence in several calls rather than losing it to that cap. Host facts are bounded further: a worker may write facts only for hosts its run has already reported results for, so nothing can be recorded about a machine no run claims to have touched.
What that does and does not give you: a worker authors its own results, so a worker you do not trust can still describe its own run untruthfully. What it cannot do is reach past that run into the recorded state of the rest of the fleet. Give a queue only to workers you would let touch the hosts that queue targets.
