Part of the user manual: the network-facing control-plane API a remote agent
uses instead of the CLI/MCP frontends' unix socket. It is off by default
(http.enabled: false — see CONFIGURATION.md) and, once
enabled, binds 127.0.0.1 unless configured otherwise. Reaching it from
another machine is the operator's own tunnel (Tailscale, cloudflared, a
reverse proxy) — Simlock does no TLS termination in v1, and Authorization
is required on every API route regardless of how it's reached, loopback
included (see Authentication for the two exceptions).
This frontend calls the exact same in-process Dispatcher the unix socket
calls — not a second copy of role/ownership logic, and not a
loopback hop through the socket either. Every route that maps onto a daemon
operation gets the same input parsing, role check, authorize/ownership
hook, and startup-readiness parking a socket request gets, from that one
shared instance. This is also why a fix on the socket side (the download
policy, startup-readiness parking, error mapping) lands on HTTP for free —
see the two bug fixes called out below for what
this actually changed.
A lease granted through this API is the same kind of lease simlock lease
gets on the unix socket: it carries a TTL, it
is kept alive by POST /v1/leases/:id/renew arriving before expiresAt, and
nothing else keeps it alive. There is no connection-liveness mode to be the
odd one out from — HTTP is stateless, and so is the lease model now, on every
transport. Closing a connection, dropping a tunnel, or losing the client
releases nothing; a lease that stops renewing expires at expiresAt and its
device is reclaimed normally.
That is the whole liveness story here, and it is worth stating plainly what
it costs: a client that vanishes without calling DELETE /v1/leases/:id
holds its device until expiresAt — at most the lease's own TTL after its
last renew: lease.defaultTtlMs unless the request asked for more, never
more than lease.maxTtlMs. Ask for a shorter ttlMs on the request if you
want a tighter bound.
Acquisition is an async resource, not a blocking call: POST /v1/lease-requests returns as soon as the request exists (queued, or already
past that), and the client polls, long-polls, or streams its progress to a
terminal state. No route blocks on device work in flight.
Every route requires Authorization: Bearer slk_<secret> except two: GET /v1/healthz, and the web console's own files, which the daemon serves at
every path outside /v1 (see CONSOLE.md). They hold no data;
the console reads everything it shows from the routes below, with the
operator's token. Missing or unrecognized tokens are 401 UNAUTHENTICATED.
Tokens are minted and managed with simlock token (see CLI.md).
Since 0.3.0 token.create|list|revoke are daemon operations (admin role) and
the daemon is the only process that reads or writes tokens.json, so unlike
config set these do go through the daemon. Each token record
is
{ id, role, label?, createdAt }, hashed at rest in ~/.simlock/tokens.json
(SHA-256; the plaintext secret is shown exactly once, at create, and never
persisted). The token id doubles as the requester identity over HTTP: unlike
the CLI's --agent-id/SIMLOCK_AGENT_ID, identity is never client-declared
here, so the one-lease-per-requester rule keys off which token authenticated
the request, not anything the request body says.
Three roles:
| Role | Can |
|---|---|
agent |
catalog, status, installed components, its own lease requests and leases, exec on its own lease |
operator |
everything agent can, plus every other requester's leases/devices, the token records, the worker routes, event replay/stream, and releasing any lease |
worker |
open an uplink at /v1/uplink, and nothing else |
A valid token with the wrong role for a route is 403 FORBIDDEN, not 401 —
distinct from an unrecognized token. Reaching another requester's own
resource (a lease/request an agent token didn't create) is the same 403,
enforced per-resource rather than as a role gate.
The worker role is deliberately not a superset or a subset of the other
two, it is disjoint from both. /v1/uplink is the one route a join token
opens and the only route it opens: presented on any other /v1 route it is
403, and an agent or operator token presented at /v1/uplink is 403
just the same. Join tokens are minted with simlock token create --role worker on the gateway, and tokens never cross machines — a gateway's
tokens are valid on that gateway and nowhere else.
An agent token on a worker is a host-level credential, not a
device-level one. POST /v1/leases/{id}/exec runs the command's arguments
on the worker's own filesystem with the daemon's own uid and environment
(args are never parsed beyond the driver's refusal list — see
POST /v1/leases/{id}/exec), so any lease at all is
enough to write or read files anywhere that uid can reach, not only the
leased device: adb pull <device-path> <worker-path> is a host-side
arbitrary file write, and there is no argument grammar this API parses that
would stop it. Size a worker's
trust boundary around the daemon's own uid, not around "one device," before
handing an agent token to something you would not otherwise let run on
that machine.
All routes are under /v1, JSON bodies both ways, additive evolution only —
new fields, never removed or repurposed ones. That rule has been broken
twice, each time as a deliberate 0.x-only exception, and each break is
called out where it applies below:
modeis gone from the lease record the operator routes serialize (GET /v1/status,GET /v1/leases), andlastRenewedAtand the storedttlMsare new on it — there is one kind of lease now, and the fields that described the split went with it.- A
ttlMsabovelease.maxTtlMsis400 BAD_REQUESTwhere it used to be accepted. - The
ttlMsa lease reports is the lease's own width, not a value the HTTP frontend remembered per request. - The lease's
slimboolean is gone; it carriesmodeinstead —"slim"or"full", the device mode of the granted device. Every device inGET /v1/statuscarries the samemode.
Routes, status codes, and every other field are unchanged.
Gateway/worker fleet mode is purely additive on top of
that: the same routes answer identically whether the daemon behind them is a
worker or a gateway fronting a fleet of workers (except
POST /v1/components/install, GET /v1/components and
DELETE /v1/components/{platform}/{version}, which only a worker serves; a
gateway answers 501 UNSUPPORTED_IN_GATEWAY_MODE), and what it adds are new
routes (/v1/uplink, /v1/workers*, POST /v1/leases/{id}/exec), new
fields (mode on status, workers[], workerId, worker on the lease),
and new error codes. Nothing existing changes shape, and a client that
ignores every one of them keeps working against a gateway unchanged.
Unauthenticated liveness for tunnels/load balancers. → 200 {"ok":true}.
Role: agent. The same view simlock status --json reads: a daemon block,
managed/running capacity per platform, active leases, managed devices (each
with its device mode, mode: "slim" or "full"), queue depth.
The daemon block carries health (starting/running) and mode
("worker" | "gateway"), the one field that tells a client which kind of
daemon answered:
{ "daemon": { "health": "running", "mode": "worker", "consoleUrl": "http://127.0.0.1:4700/" } }consoleUrl is the web console's address (see CONSOLE.md):
http://<http.host>:<http.port>/, with localhost for a host of 0.0.0.0 or
:: and an IPv6 host in brackets. It is present whenever HTTP is on, so an
HTTP answer carries it; over the unix socket it is absent while HTTP is off.
It is also left out if the address would be longer than 512 characters, which
no real host name is. An older daemon sends none.
Beside it, host says what machine the daemon runs on: the operating
system, its version, the CPU architecture, and the version of each platform
tool its drivers use:
"host": { "os": "macOS", "osVersion": "15.5", "arch": "arm64",
"tools": [ { "platform": "ios", "name": "xcode", "version": "16.4", "build": "16F6" },
{ "platform": "android", "name": "emulator", "version": "35.4.9" },
{ "platform": "android", "name": "platform-tools", "version": "36.0.0" },
{ "platform": "android", "name": "cmdline-tools", "version": "19.0" } ] }The daemon works these out from the machine; no config key changes them. Tool
versions are read at start and again in the background once a minute has
passed, so this endpoint never waits for them. Right after a start, or for a
tool that is not installed, tools has no entry for it; if a later read
fails, the last version read stays.
Under the resource capacity strategy, capacity.ramBudget reports the
RAM budget; under fixed the field is absent:
"ramBudget": { "limitBytes": 12884901888, "usedBytes": 4831838208, "overLimit": false }limitBytes is the machine's RAM minus 4 GiB left for the OS. usedBytes is
the sum over every listed device that is not deleted, each at the size of the
mode it reports, so it always matches the device list. overLimit is true
when the use exceeds the limit, for example after a restart with larger
per-device sizes; until a device is deleted, none is created, and no
shut-down slim device boots if its slim size is smaller than the full size.
installs lists the component installs waiting or running on the
machine, whoever started them (a lease request that allowed a download, for
example), oldest first and at most 16:
"installs": [
{ "platform": "ios", "component": "26.4", "state": "downloading", "since": 1790864071200, "waiters": 2 },
{ "platform": "ios", "component": "26.5", "state": "waiting", "since": 1790864075000, "waiters": 1 }
]state is downloading while the platform's installer runs, and waiting
while the install is queued behind another one on the same platform or about
to start. component is the version, or the word the platform uses for its
newest. since is when the first request for that install arrived, in
milliseconds since the epoch, and waiters is how many requests wait on it.
An install leaves the list as soon as it ends, whether it succeeded, failed
or timed out. A worker always sends the field, empty when nothing is
installing; an older daemon leaves it out.
waiting lists the requests waiting in this daemon's own queue for a
device, oldest first, in the shape
GET /v1/lease-requests answers. A worker always
sends it, empty when nothing waits; an older daemon leaves it out.
A device in devices that is provisioning or reclaiming carries
transitionAgeMs, how long it has been in that state. One that has been there
past its threshold with nothing working on it also carries
stalled: true: the same devices simlock doctor reports as stalled (see
stalledTransition.* in CONFIGURATION.md). Every other
device has no stalled field, and an older daemon sends none.
{ "id": "dev_7", "state": "provisioning", "mode": "full", "transitionAgeMs": 412000, "stalled": true,
"spec": { "platform": "ios", "model": "iPhone 16", "osVersion": "18.4" } }On a gateway the numbers are the fleet's — capacity summed across connected
workers (ramBudget over the workers that report one, overLimit when any of
them is, absent when none does), every gateway-issued and local lease, every device, the
connected workers' installs (the 16 oldest across the fleet), the gateway
queue's depth and the requests waiting in it — every lease, device and install
carries the workerId it lives on, and an additive workers array
carries one
worker view per worker:
{ "daemon": { "health": "running", "mode": "gateway", "consoleUrl": "http://127.0.0.1:4700/" },
"host": { "os": "Linux", "osVersion": "6.8.0", "arch": "x64", "tools": [] },
"workers": [ { "id": "3f81a2c4", "label": "mac-studio-2", "connection": "connected", "drained": false } ] }A gateway's own host is the gateway's machine and has no tools, since it
runs no drivers; each worker's is on its worker view. On a worker, workers
is absent and workerId never appears. A client that
reads neither cannot tell the difference, which is the point.
Role: agent. Exactly simlock catalog --json. Read-only; never triggers a
download, and lists only what is installed, whatever the download policy. On
a gateway it is the union of the connected workers' catalogs, each model
and runtime annotated with the workers that have it — so a model the catalog
lists is leasable somewhere in the fleet, not necessarily on every machine
in it. On a daemon, a platform whose tools cannot be read is left out, unless
platform names it: then the call fails with that error.
Each platform entry carries modelRuntimes: for every name in models, the
installed runtimes that model pairs with. A pair listed there can be leased;
a model and a runtime that are each listed but not paired cannot. An empty
list means nothing installed pairs with that model. On a gateway a model is
paired with a runtime when at least one connected worker pairs them, and the
gateway sends a request only to a worker that pairs them.
Each entry also carries modelAliases: for a name in models, the other
names a lease request may use for it, in any letter case. Only models that
have another name appear; on Android that is a built-in profile's AVD id,
and iOS has none. An Android entry also carries images, every installed
system image with its API level (runtime, a value from runtimes), tag,
and abi, including an image whose ABI the host cannot run natively; an iOS
entry has no images. On a gateway modelAliases is the union per model and
images the union of each worker's images, absent when no worker's entry
for that platform has an images field. A gateway accepts any name a worker
lists for a model, in any letter case, and sends that worker its own name for
it.
An entry may also carry customModels: the names from models that exist
because of something on that machine rather than the platform's tools. On
Android that is a profile made with Android Studio's device manager, read
from devices.xml; when a built-in profile has the same name, the built-in
one wins and the model is not custom. The field is absent when there are
none, and iOS never has it. On a gateway a model is listed when any worker
that lists it marks it custom; each worker's own list is in its catalog in
GET /v1/workers.
{ "platforms": [ {
"platform": "ios",
"models": ["iPhone 16", "iPhone XS"],
"runtimes": ["18.4", "26.0"],
"defaultRuntime": "26.0",
"modelRuntimes": { "iPhone 16": ["18.4", "26.0"], "iPhone XS": ["18.4"] },
"modelAliases": {}
}, {
"platform": "android",
"models": ["My Tablet", "Pixel 8"],
"runtimes": ["34", "35"],
"defaultRuntime": "35",
"modelRuntimes": { "My Tablet": ["34", "35"], "Pixel 8": ["34", "35"] },
"modelAliases": { "Pixel 8": ["pixel_8"] },
"customModels": ["My Tablet"],
"images": [ { "runtime": "34", "tag": "default", "abi": "x86_64" },
{ "runtime": "35", "tag": "google_apis", "abi": "arm64-v8a" } ]
} ] }Role: agent. Enqueues a device request.
{
"platform": "ios",
"device": "iPhone 17 Pro",
"os": "26.5",
"ttlMs": 900000,
"timeoutMs": 300000,
"noWait": false,
"allowDownload": false,
"mode": "slim"
}platform and device are required; os defaults to the newest installed
runtime; ttlMs defaults to lease.defaultTtlMs and is 400 BAD_REQUEST
above lease.maxTtlMs; timeoutMs (optional) is enforced daemon-side so a
vanished client can't hold a queue slot forever. mode (optional, "slim"
or "full") is the device mode the request asks for; without it the request
gets the default mode of the worker that serves it (ios.defaultMode, full
unless configured). full is a guarantee: a full request never gets a slim
device. slim is best effort: on a runtime that cannot be slimmed (an iOS
runtime older than 18.5, or any Android device) the request is granted a full
device. A request only reuses an idle device of the mode it resolved to, so it
can wait for a fresh device even while devices of the other mode sit idle. See
CONFIGURATION.md for what a slim
device leaves out.
imageTag (optional, Android only) is the system image type to create the
device from, such as "google_apis_playstore" or "default": the tag of an
image GET /v1/catalog lists. Without it, Simlock picks the image itself:
google_apis for the host's ABI when installed, otherwise another installed
image of that API level. With it, the device is created from an installed image
of that tag, the host's ABI first; without os the request gets the newest API
level that has one. A request with imageTag never downloads: when no image of
that tag is installed for the API level it fails with RUNTIME_MISSING,
whatever allowDownload says. It only reuses an idle device created for the
same tag, and a request without imageTag only one created for none. On iOS
imageTag is 400 BAD_REQUEST. Through a gateway, a request whose tag no
worker lists for that API level, an iOS one included, fails at once with
422 RUNTIME_MISSING, with or without noWait.
The body is strict: a key this route does not know, a mode other than
"slim" or "full", or an imageTag that is not 1 to 64 letters, digits,
_, . or -, is 400 BAD_REQUEST.
allowDownload is now clamped through config.downloads.policy the same
way the socket protocol always was (bug fix, 0.3.0): before this
release, HTTP passed a client-supplied allowDownload straight through
unclamped, so a "never" policy could still be bypassed over HTTP even
though it already blocked the same thing on the socket. Both frontends now
go through the one shared dispatcher, so there is only one place left to get
this wrong.
An Idempotency-Key header (1 to 200 characters) makes the request
repeatable. Simlock stores every request before it queues it, and with a key
it stores it under that key for your token. Sending the same request again
with the same key returns 201 with the stored request instead of queueing
a second one: its current state while it is still waiting, or its result
once it has one. A result is never worked out again, even if the machine has
changed since — a request that failed with NO_CAPACITY stays failed. To
try again, use a new key. Repeating works across a daemon restart, for
lease.requestRetentionMs after the request finished (see
CONFIGURATION.md). The same key with a different
platform, device, os, mode, or imageTag is 409 IDEMPOTENCY_CONFLICT. Keys
belong to your token: another token sending the same key starts a request of
its own.
Through a gateway, allowDownload does not change which worker is picked
and never starts a download: only runtimes already installed on a worker
count. It still makes the POST answer early, as described below. The
gateway sends the request only to a worker whose catalog can serve it: one
that lists device as a model or another name for one, in any letter case,
and pairs that model with os (or, without os, with at least one installed
runtime). With imageTag, the worker's catalog must also list an image of that
tag, for os when it is given. The worker is sent its own name for the model.
A gateway fails a request no worker can serve at once, with or without
noWait and timeoutMs, instead of queueing it. A worker takes requests
when it is connected, not drained, and the gateway has read its catalog since
it connected; the gateway knows a worker once it has read a catalog from it
and the worker is not incompatible, so a drained or disconnected worker stays
known and a reconnecting one stays known from its last catalog.
| Situation | Result |
|---|---|
| No worker takes requests | 503 NO_CAPACITY |
| A worker takes requests, and no known worker has the platform | 422 NO_DRIVER |
| ... and no known worker lists the model | 422 UNKNOWN_MODEL |
| ... and no known worker has the runtime, or can pair it with the model | 422 RUNTIME_MISSING, with downloadable: false and osVersion: "default" when os is absent |
| A known worker could serve it, but none that takes requests can | 503 NO_CAPACITY |
| A worker that takes requests can serve it but is busy | queues; 503 NO_CAPACITY only with noWait: true |
A request that is already waiting is held to the same table: when the only
worker able to serve it is drained or disconnects, the request ends failed
with NO_CAPACITY. After a gateway restart a worker is not known until it
reconnects.
On a single host, with allowDownload: true the 201 is returned as soon as
the request is stored — resolving a downloadable runtime can take minutes, so progress and
any later failure surface on the request resource instead of on the POST
itself. A refusal that comes before the request is stored
(409 REQUESTER_ALREADY_LEASED, 400 for a ttlMs above lease.maxTtlMs)
still fails the POST. Through a gateway the flag changes nothing about the
POST: it never downloads, so a request no worker can serve fails it as
above.
→ 201, Location: /v1/lease-requests/{id}:
{ "request": { "id": "req_7d1a", "state": "queued", "queuePosition": 2, "createdAt": "2026-09-01T09:12:00Z" } }A rejection that lands before any device work is claimed for the request
fails the POST itself instead of the client having to poll to learn about
it: 409 REQUESTER_ALREADY_LEASED (names the existing lease id), 422 for
an unknown model / missing runtime / no driver, 503 NO_CAPACITY (with
Retry-After) when noWait is set. A gateway also answers these at once for
a request no worker can serve, 503 NO_CAPACITY included, whatever noWait
says (see the table above). Anything that fails once device work is
already in flight surfaces as the request resource's terminal failed state
instead — see the state list below.
Role: agent (its own requests; operator sees all). Poll the request.
?wait=<seconds> long-polls: returns as soon as the state changes, else
once wait elapses. wait is capped at 60 seconds — a larger value is
clamped, not rejected, and the poll simply returns (unchanged) sooner than
asked; re-poll to keep waiting.
States: queued | downloading | reclaiming | provisioning | booting | granted | failed | cancelled, carrying queuePosition (queued) or
etaSeconds (reclaiming/provisioning/booting) where the stage has one.
Terminal granted embeds the lease object; terminal
failed embeds { code, message }.
downloading is a request with allowDownload: true waiting on the download
of a missing runtime, before any device work:
{ "request": { "id": "req_7d1a", "state": "downloading", "component": "26.4", "waiting": false, "percent": 41, "createdAt": "2026-09-01T09:12:00Z" } }component names what is being downloaded, as the platform names it: an iOS
version or an Android API level, for example. waiting is true while
another download on the same platform runs ahead of this one, and false
once this download runs. percent, a whole number from 0 to 100, is there
when the platform's installer printed one. A request that joins a download
already running shows that download's latest state at once. A request that
needs no download never shows this state.
The request is the one Simlock stored, so GET answers across a daemon
restart and for a request your token sent over the socket too. A finished
request answers for lease.requestRetentionMs, then 404. A granted
request's lease object is the lease as it was granted: after a renew, read
the current deadline from GET /v1/leases/{id}.
Role: agent (ownership as above). Server-Sent Events stream of the same
progress objects, one event per state change, named after the state
(queued, downloading, provisioning, ...), ending with granted or
failed. A : keepalive comment every ~15s keeps idle tunnels from closing
the stream.
Role: agent (ownership as above). Cancel a pending request.
→ 204 if it was still cancellable (no device work claimed for it yet).
409 REQUEST_NOT_CANCELLABLE once a download or device work is in flight, or the request
already reached a terminal state — the body names the lease id if it was
granted (release that instead). 404 UNKNOWN_LEASE_REQUEST if unknown.
A lease issued by a gateway — a worker's own lease is the same object
without the workerId and worker fields:
{ "lease": {
"id": "3f81a2c4.lse_9f2c", "requestId": "req_7d1a",
"platform": "ios", "device": "iPhone 17 Pro", "os": "26.5",
"udid": "ABCD-...", "deviceId": "dev_1a2b",
"workerId": "3f81a2c4", "worker": { "id": "3f81a2c4", "label": "mac-studio-2" },
"createdAt": "2026-09-01T09:14:07Z",
"expiresAt": "2026-09-01T09:29:07Z", "ttlMs": 900000,
"dataPlane": null, "mode": "slim"
} }worker (and the flat workerId, which mirrors it for the aggregate lists)
is present when a gateway issued the lease and absent from a worker's own
— it says which machine the device lives on, so a client and the console can
show it. label is display-only. A worker's network address is deliberately
never here: clients reach the device through the gateway, with
POST /v1/leases/{id}/exec.
The lease id names its worker (<workerId>.<worker's own lease id>, split
on the first .), which is how a gateway routes renew, release, and
reads with no state of its own to lose across a restart. A worker id is a
UUID, so a real id reads
3f81a2c4-9b7d-4e21-8a55-1c0e6f2d7b93.lse_9f2c; the examples here and
elsewhere in these docs abbreviate it to its first segment for legibility.
Treat the whole id as opaque — pass it back verbatim in paths and bodies,
and read worker.id when you want the machine.
dataPlane is reserved and always null in this version: streaming a
device's screen, forwarding a port, or opening an interactive TTY is a
separate, not-yet-implemented concern — see Not
implemented below. It is in the schema now so its arrival
is additive rather than a breaking shape change. Running a command on the
device is not part of it and does not wait for it: that is exec, below.
mode is the device mode the granted device actually has: "slim" when its
feature set was reduced, "full" otherwise — always "full" for Android. A
"slim" request can be granted "full" (slim is best effort); a "full"
request is never granted "slim". It lets a client explain a
feature-loss failure (missing push notification, Spotlight result,
StoreKit sheet, universal link, or system picker) instead of misreading it
as a bug.
imageTag is present on a lease whose request named one: the image tag the
device was created from. A lease whose request named none has no imageTag.
Role: agent (own lease; operator any). Re-fetches the lease — a client
that restarts mid-lease recovers its state instead of leaking the lease.
404 UNKNOWN_LEASE both once it has expired or been released, and for
another requester's own, still-live lease: this route (and GET /v1/leases/{id}/events below) has no dispatcher operation to defer to for a
single-lease read, so it resolves the lease the same way lease.list's
handler already filters leases — to the session's own set, admin sees all —
and an id outside that set simply isn't in the list. 404 covers "doesn't
exist" and "not yours" identically, the same way lease.list itself does
not distinguish them.
POST /v1/leases/{id}/renew, DELETE /v1/leases/{id}, and POST /v1/leases/{id}/exec are different: all three dispatch their operation
(lease.renew, lease.release, device.exec) directly, so another
requester's own, still-live lease answers 403 FORBIDDEN from them — the
same answer the socket transport gives, via the same operation's ownsLease
authorize hook.
This is different again from the lease-request routes below
(/v1/lease-requests/{id} and friends), which answer 403 FORBIDDEN for a
request another token sent.
expiresAt is always the authoritative deadline, and ttlMs is always the
lease's own width — the TTL it was granted with, or last renewed with when a
renew carried one. The daemon stores it on the lease record, so it survives a
daemon restart along with the deadline and the restored TTL timer.
requestId names the request that was granted this lease. It is present
while that request is still stored, which is lease.requestRetentionMs after
it was granted, and omitted after that. Schedule renewals from expiresAt rather than from
ttlMs all the same: the deadline is the fact, the width is how far the next
body-less renew will push it.
Role: agent (own lease). Body { "ttlMs": 900000 } — optional, and
omitting it re-applies the lease's own ttlMs rather than
lease.defaultTtlMs, so a lease keeps the width it was granted with. A
ttlMs above lease.maxTtlMs is 400 BAD_REQUEST; one below it changes the
lease's width from this renew on. Either way the deadline resets to now + ttl,
regardless of how much time was left. This is the only thing that keeps a
lease alive.
→ 200 { "leaseId": "lse_9f2c", "expiresAt": "...", "notices": [] }
notices is an HTTP-side convenience, not part of the socket contract's
lease.renew response: LeaseNoticeBuffer (src/http/notices.ts) collects
the owner-routed device-health facts a socket client would have received as
pushes, and drains them here. It carries the facts observed since the
previous renew for this lease — {"event":"device_unhealthy"},
{"event":"device_recovered","attempts":1} — so a polling-only client
learns its device blinked without holding a stream open.
Role: agent (own lease). Server-Sent Events for live health pushes on this
lease: device_unhealthy, device_recovered, lease_lost (ends the
stream). The same facts a running simlock lease relays on stderr. Losing
this stream tells you nothing about the lease — it is still yours until
expiresAt; reconnect, or read the health facts from renew's notices.
lease_lost is the one fact notices cannot carry: it ends the lease, so
there is nothing left to renew and nothing to drain the buffer on. A polling
client learns it the other way round — the next POST /v1/leases/{id}/renew
against an ended lease answers 404 UNKNOWN_LEASE.
Role: agent (own lease); operator must name the lease's requester — see
below. Runs one simctl/adb command on the machine that owns the
device and streams its output back. This is what makes a leased device
drivable from here at all — over HTTP against a lone worker, and through a
gateway to whichever worker holds the lease, with the same request and the
same response either way.
Every other way of reaching a device assumes the caller shares that
machine's filesystem.
{ "tool": "simctl", "args": ["list", "devices"], "stdin": "y\n" }tool names a driver passthrough — simctl or adb on the machines this
version runs on. The contract does not close that set; the drivers installed
there do, and one they do not claim is 422 UNKNOWN_PASSTHROUGH_TOOL. args
is the argument vector, passed through unchanged. The daemon that owns the
device resolves it through the same driver passthrough logic simlock simctl / simlock adb use locally — the same root scoping (--set for
iOS, -P for Android, supplied by simlock and refused from the caller), and
the same refusal list for verbs that would change a device's lifecycle
behind the registry's back (create/erase/delete, shutdown all,
runtime delete, kill-server, emu kill, …). A refused verb is 422 PASSTHROUGH_REFUSED here too and nothing is spawned for it. Nothing else
about the arguments is parsed, and the device is named by them, not by the
lease: the lease id is the ownership proof. A gateway in the path parses
none of this either — it proxies the call to the owning worker and relays
the stream back unchanged.
stdin (optional) is a single string, sent with the request, written to the
command once, and the pipe is then closed — there is no incremental stdin
channel and no pseudo-terminal, so line-oriented commands work and
full-screen or interactive ones do not: a bare adb shell, which is the
interactive shell, is refused (422 PASSTHROUGH_REFUSED, "needs a
terminal") rather than left to hang on a pipe until the timeout.
requesterId (optional) names the agent this command is being run for,
and is read only from an operator token — the proxying case a
gateway's own admin session needs, fronting many agents over one uplink
credential. Identity is otherwise never client-declared here (see
Authentication): an agent token is authorized against
the lease it holds, exactly as it is for renew and release, and if it
supplies a requesterId at all — even its own — the call is 403 FORBIDDEN.
That rejection lives in device.exec's own authorize hook, not this
route, so the unix socket and any future transport answer it the same way;
answering a request that named an identity as if it had named none would
read like authorization. An operator token is held to this field instead
of getting the usual operator bypass, and does not default past it: omitted,
it falls back to the operator's own principal, and either way it must name
the requester the lease was actually granted to, or the call is the same
403 FORBIDDEN. That is what lets a gateway proxy many agents over one
operator credential without any of them reaching another's device —
ownership ends up checked on both hops, the gateway against its own lease
index and the worker against the lease it actually holds.
→ 200, Content-Type: text/event-stream, the same SSE shape
/v1/lease-requests/{id}/events uses. One event per chunk of output as it is
written, then exactly one terminal event:
event: output
data: {"stream":"stdout","chunk":"== Devices ==\n"}
event: output
data: {"stream":"stderr","chunk":"No devices found\n"}
event: exit
data: {"exitCode":0}
chunk is whatever the command wrote, decoded as UTF-8 and forwarded
unsplit — not a line, not a frame — so a command whose output is genuinely
binary (simctl io booted screenshot - to stdout) is not supported over
this route; have it write to a file on the device's own machine instead.
Output is streamed and never buffered by the daemon, so there is no size cap
and the first chunk arrives while the command is still running; a client
that wants lines assembles them itself. A : keepalive comment every ~15s
keeps idle tunnels open, as elsewhere.
A failure that lands before the command is spawned is answered as an ordinary JSON error with its own status instead of a stream:
403 FORBIDDEN— another requester's lease (dispatched through the operation's own ownership hook, like renew and release), anagenttoken that supplied arequesterIdat all, or anoperatortoken whoserequesterIddoesn't match the lease's.404 UNKNOWN_LEASE— no such lease, or it has expired or been released.400 BAD_REQUEST— a malformed body, nothing more.422 PASSTHROUGH_REFUSED— a refused verb, a caller-supplied--set/-P, or a bareadb shell.422 UNKNOWN_PASSTHROUGH_TOOL— atoolno driver on that machine claims.503 WORKER_UNREACHABLE— a gateway could not reach the worker holding the lease.
The status commits at the spawn, not at the first byte: the moment the
child process exists the response is 200 and the stream is open, even if
the command has not written anything yet (simctl install on a large
bundle says nothing for a while, and a client should not have to guess
whether that silence means the request was accepted). Everything after that
point arrives as a terminal event instead:
event: error
data: {"error":{"code":"EXEC_TIMEOUT","message":"..."}}
EXEC_TIMEOUT is the daemon killing a command that outran exec.timeoutMs
(ten minutes by default on the worker, see
CONFIGURATION.md) — reported instead of the exit code
the kill produced, because "we stopped it" and "it failed" are different
answers. The worker's own exec.timeoutMs is authoritative, since that side
owns the process and is the only one that can kill it; a gateway in the path
adds gateway.execTimeoutMs (eleven minutes) only as a backstop for a
worker that never answers at all — deliberately the longer of the two, so an
ordinary timeout surfaces as the worker's own EXEC_TIMEOUT rather than
racing the gateway's. EXEC_TIMEOUT is 504 in the contract's error table,
though on this route the status never reaches the client: the response is
already 200 and streaming by the time a command can time out, so it
arrives as the stream's terminal error event instead; the code is
documented anyway for a client mapping it without a route in front of it.
Disconnecting does not kill the command — the daemon simply stops
writing its output, the same way closing a connection releases no lease;
the timeout is what bounds it.
Paths in args resolve on the daemon's filesystem
({"tool":"simctl","args":["install","booted","/build/MyApp.app"]} needs
that path to exist there). Getting a file onto that machine is not part of
this API in this version — see Not implemented.
Role: operator. Installs one iOS simulator runtime or Android system image
on this machine, without leasing a device — the same operation as
simlock component install.
{ "platform": "android", "version": "35" }version is the string GET /v1/catalog lists under runtimes once the
component is installed (26.4 for an iOS runtime, 35 for an Android API
level): 1 to 64 characters, none of them whitespace or a control character, and
not starting with -.
The request is the consent to download, so it needs no allowDownload; under
downloads.policy: "never" it is refused (see
CONFIGURATION.md).
A failure that lands before the install takes the request is answered as an ordinary JSON error with its own status:
403 FORBIDDEN— anagenttoken.403 DOWNLOADS_DISABLED—downloads.policyis"never"; nothing is downloaded.400 BAD_REQUEST— a malformed body or version.422 NO_DRIVER— no driver for that platform on this machine.501 UNSUPPORTED_IN_GATEWAY_MODE— the daemon is a gateway, which owns no components, and the body names noworkers; name the workers to install on (below).
Once the install has taken the request the response is 200,
Content-Type: text/event-stream, the same SSE shape the exec route uses:
progress events while it runs, then exactly one terminal result or
error event.
event: progress
data: {"stage":"waiting"}
event: progress
data: {"stage":"downloading","fraction":0.41}
event: result
data: {"platform":"android","component":"35","outcome":"installed","version":"35"}
waiting means another download on the same platform runs first; a
platform downloads one component at a time. fraction, from 0 to 1 with at
most three decimals, is present when the platform's installer reports one;
an install that ends installed reports 1 before its result. outcome is installed,
or already-installed when the component was already there (repeating the
request changes nothing); version is the exact version installed. A failure
after that point is the terminal error event, in the exec route's shape —
INSUFFICIENT_DISK_SPACE (checked before the download starts),
LICENSE_NOT_ACCEPTED, or DOWNLOAD_TIMEOUT when downloads.timeoutMs,
counted from the request and waiting included, runs out. A : keepalive
comment every ~15s keeps idle tunnels open.
Disconnecting does not stop the install. Repeating the request while it
runs joins it and gets its result; repeating it after it finished answers
already-installed. A lease request for the same component joins the same
download too.
Against a gateway, add workers: a list of 1 to 64 worker ids, as
GET /v1/workers shows them, or "all" for every connected worker.
{ "platform": "android", "version": "35", "workers": "all" }The gateway asks every targeted worker at the same time, and each one
installs the component itself under its own downloads.policy, Android
license setting, disk check and downloads.timeoutMs. A worker set to
downloads.policy: "never" refuses; the gateway has no download policy of
its own and stores or forwards no image. A drained worker is asked like any
other. workers on a single host is 501 UNSUPPORTED_IN_WORKER_MODE; leave
it out to install on that host.
Before any worker is asked, these are JSON errors:
403 FORBIDDEN— anagenttoken.400 BAD_REQUEST— a malformed body, version, or worker list (empty, more than 64, or an id twice).404 UNKNOWN_WORKER— a named id the gateway does not know.503 WORKER_UNREACHABLE— a named worker that is disconnected, speaks an incompatible protocol, or whose config the gateway has not read yet.
Then the response is the same event stream. Each progress event carries the
workerId it came from, and the terminal result event lists one entry per
worker, in ascending worker id:
event: progress
data: {"stage":"downloading","fraction":0.41,"workerId":"3f81a2c4"}
event: result
data: {"results":[{"workerId":"3f81a2c4","label":"mac-studio-2","outcome":"installed","version":"35"},{"workerId":"9b07de11","outcome":"refused","error":{"code":"DOWNLOADS_DISABLED","message":"..."}}]}
outcome is one of:
installed/already-installed— the worker's own answer, with the exactversion.refused— the worker'sdownloads.policyis"never".failed— the worker answered with another error;error.codeis the worker's own.skipped—"all"only: the worker could not be asked (disconnected, incompatible, or its config not read yet). It is not asked when it comes back.unknown— the worker's connection dropped during its install, or it did not answer within its owndownloads.timeoutMsplus one minute. The install carries on on the worker;GET /v1/catalogshows the component once it is there and the worker is connected.
Every outcome but the first two carries error: {code, message}. A
worker's new component is in the gateway's GET /v1/catalog by the time the
result event is sent, unless the gateway could not read that worker's
catalog just then; its next read brings it in. Nothing is retried or kept for
later.
Role: agent. Lists every iOS simulator runtime and Android system image
installed on this machine, whoever installed it — the same answer as
simlock component list. It changes nothing. Without platform, both
platforms are listed.
{
"components": [
{
"platform": "android",
"version": "35",
"variant": "google_apis/arm64-v8a",
"sizeBytes": 4201234567,
"installedBySimlock": true,
"installedAt": 1790864071200,
"devices": 2,
"foreignDevices": 0
}
]
}Entries are ordered by platform, then version, then variant. version is
the string GET /v1/catalog lists under runtimes; variant tells two
components of one version apart (an iOS runtime's build, an Android image's
tag and ABI). sizeBytes is left out when it cannot be read.
installedBySimlock is true only for a component Simlock installed that is
still the same one on disk, and installedAt is when it did. devices
counts Simlock's own devices of this platform and version that have not been
deleted; two variants of one version show the same count. foreignDevices
counts the devices outside Simlock that use the component: simulators in
Xcode's default device set that have been booted at least once, and every
AVD in the user's own AVD home. The simulators macOS creates by itself for
each runtime it installs do not count until someone boots one.
A platform whose tools cannot answer is left out. 400 BAD_REQUEST for a
platform other than ios or android; 501 UNSUPPORTED_IN_GATEWAY_MODE
from a gateway, which owns no components — ask the worker.
Role: operator. Removes one iOS simulator runtime or Android system image
that Simlock installed on this machine, to get its disk back — the same
operation as simlock component remove. ?dryRun=true runs every check a
removal runs and removes nothing.
DELETE /v1/components/ios/26.4
DELETE /v1/components/ios/26.4?dryRun=true
The answer is one JSON body, not a stream:
{ "platform": "ios", "version": "26.4", "outcome": "removed", "sizeBytes": 9103456789 }outcome is removed, or would-remove for a dry run. sizeBytes is the
disk the component took, left out when it could not be read. residue
appears when something stayed behind after the removal, and names each
thing. On iOS that is the runtime's never-booted simulators in Xcode's
default device set, which become unavailable (residue says how many, and
that xcrun simctl delete unavailable clears them), and the runtime's
download when macOS keeps it in its own asset store (remove the platform in
Xcode's Settings, under Platforms). Simlock deletes neither.
A removed component leaves GET /v1/catalog and GET /v1/components at once,
and the removal is reported as a component.removed event naming who asked.
A component is removed only when Simlock installed it and it is still the same one on disk, no device uses it, and nothing else installs or removes on that platform. Each refusal removes nothing:
409 COMPONENT_NOT_OWNED— Simlock did not install it, or it changed on disk since. There is no way to force it.409 COMPONENT_IN_USE— a device uses it: one of Simlock's, in any state, leased ones included, or one outside Simlock (a simulator in Xcode's default device set that has been booted at least once, an AVD in the user's own AVD home). Never-booted simulators do not block it. The body carriesdevicesandforeignDevices, how many of each.409 COMPONENT_BUSY— an install or removal runs or waits on that platform; try again once it ends.403 FORBIDDEN— anagenttoken.400 BAD_REQUEST— a platform other thaniosorandroid, a malformed version, or adryRunother thantrueorfalse.501 UNSUPPORTED_IN_GATEWAY_MODE— the daemon is a gateway, which owns no components; ask the worker.
{ "error": { "code": "COMPONENT_IN_USE", "message": "...", "devices": 1, "foreignDevices": 2 } }Role: agent (own lease); operator may release any lease.
→ 202 { "released": true, "device": { "id": "dev_1a2b", "state": "reclaiming" } }
The lease is gone the moment this responds; the driver-side purge continues
in the background (existing release semantics: release hands the
purge off), hence 202, not 200.
Role: worker. The endpoint a worker dials to join a fleet: it opens
one outbound WebSocket to <gateway.url>/v1/uplink, presenting
Authorization: Bearer slk_<join-token> on the upgrade request along with
its instance id. The gateway verifies the token against its own store and
requires role worker; a missing or unrecognized token is 401 UNAUTHENTICATED, and a valid token of any other role is 403 FORBIDDEN.
Revoking the token closes the uplink.
This is the fleet's only inbound connection. Workers dial out, so a machine behind NAT, on a laptop, or on a CI runner joins with a URL and a token and needs no inbound port of its own, and no client ever learns a worker's address.
What travels over the socket is not a new API: it is the same typed daemon
contract, with the gateway as the protocol client. It sends
hello, negotiates the protocol range exactly as over the unix socket, and
then issues ordinary operations (status.get, list.get, catalog.get,
events.subscribe, lease.request, device.exec, …) to the worker's own
dispatcher, exactly as a local admin CLI would. A worker whose range does not
overlap the gateway's is marked incompatible and never dispatched to; it
keeps serving its own local clients.
Nothing else about this endpoint is a REST resource: there is no GET body,
no polling, and no route to list uplinks. The connection is the worker's
liveness signal — GET /v1/workers is how you look at it.
Role: operator for all four. They exist on a gateway and on a single
host. A single host answers as a fleet of one: GET /v1/workers lists the
host itself, and the other three answer 501 UNSUPPORTED_IN_WORKER_MODE.
GET /v1/workers— every worker view the gateway currently holds, or the host's own view on a single host.POST /v1/workers/{id}/drain— stop dispatching new requests to this worker; it keeps the leases it already has.DELETE /v1/workers/{id}/drain— undrain it, putting it back in rotation.DELETE /v1/workers/{id}— forget a worker's view.
GET /v1/workers, a real answer from a test fleet whose drivers are
simulated (hence the thin Android catalog), trimmed to one worker:
{
"workers": [
{
"id": "2b026432-7743-4a08-98fc-ce494d11866f",
"label": "worker-a",
"connection": "connected",
"drained": false,
"lastSeenAt": 1790864080506,
"catalogReadAt": 1790864071200,
"health": "running",
"version": "1.0.0",
"capacity": {
"ios": {
"running": 0,
"maxRunning": 8,
"reserved": 0,
"overLimit": false,
"limit": 8,
"warm": 0,
"used": 0
},
"android": {
"running": 0,
"maxRunning": 8,
"reserved": 0,
"overLimit": false,
"limit": 8,
"warm": 0,
"used": 0
},
"global": {"running": 0, "maxRunning": 8, "reserved": 0, "overLimit": false, "warm": 0}
},
"downloads": {"policy": "on-request", "timeoutMs": 1200000},
"lease": {"maxTtlMs": 14400000},
"queueDepth": 0,
"leases": [],
"devices": [],
"installs": [
{"platform": "ios", "component": "26.4", "state": "downloading", "since": 1790864071200, "waiters": 1}
],
"catalog": [
{
"platform": "ios",
"models": ["iPhone 16"],
"runtimes": ["18.4", "26.0"],
"defaultRuntime": "26.0",
"modelRuntimes": {"iPhone 16": ["18.4"]},
"modelAliases": {}
},
{
"platform": "android",
"models": [],
"runtimes": ["18.0"],
"defaultRuntime": "18.0",
"modelRuntimes": {},
"modelAliases": {},
"images": [{"runtime": "18.0", "tag": "google_apis", "abi": "arm64-v8a"}]
}
],
"host": {
"os": "macOS",
"osVersion": "26.6.1",
"arch": "arm64",
"tools": [{"platform": "ios", "name": "xcode", "version": "16.4", "build": "16F6"}]
}
}
]
}catalogReadAt is when the gateway last read that worker's catalog since the
worker connected. It is absent until the first read, and again after the
worker reconnects until its new catalog arrives; a worker without it takes no
requests (see the table under POST /v1/lease-requests).
downloads.policy and downloads.timeoutMs are that worker's own effective
config, read when its uplink connects, again on every periodic refresh, and
after the worker installs a component. The policy is shown for reference;
routing does not read it, since no download is started through a gateway.
installs is the worker's own installs list from its
GET /v1/status. It is re-read when an install on that
worker starts, finishes or fails, and on every periodic refresh, so an
install queued behind another may appear only then. A worker that does not
send one shows an empty list; a disconnected or incompatible worker shows
none.
waiting is the worker's own waiting list from its
GET /v1/status: the requests waiting in that worker's
queue. It is re-read on every lease event on that worker. A worker that does
not send one shows an empty list; a disconnected or incompatible worker shows
none.
catalog is what that worker can lease, each model with the runtimes it
pairs with, and lists a newly installed component as soon as its install
ends. Its customModels are that worker's own, so this is where you see
which worker has a custom Android profile. host is the worker's machine, the same block its own
GET /v1/status reports, as of the gateway's last refresh: a tool installed
or upgraded on the worker shows here without a restart of either side.
devices are the worker's devices as its own
GET /v1/status reports them, stalled included. A device
stalls with no event to report it, so the gateway shows a new stall after its
next periodic refresh of that worker.
protocol appears only on an incompatible worker: it names both ranges,
the worker's and the gateway's, so you can see which side to upgrade. A worker
too old to overlap is the ordinary upgrade path, not a fault, and its view
carries no host, since the gateway asks it nothing.
connection is connected, disconnected, or incompatible. A worker view is
rebuilt over the uplink and never persisted, so these are current facts, not
a registry: a worker appears by connecting, and there is deliberately no
route that adds one.
A disconnected worker keeps its last-known view (greyed in the console,
never dispatched to) until an operator removes it or
gateway.disconnectedRetentionMs (24 hours) elapses. The clock is held while
the gateway still knows of gateway-issued leases on that worker — forgetting
a worker that holds someone's device is how a lease becomes unroutable — and
that hold ends when the last of those leases passes its deadline, since a
lease nobody can renew is one the worker has expired on its own clock.
POST /v1/workers/{id}/drain → 200 { "workerId": "3f81a2c4", "drained": true }; DELETE /v1/workers/{id}/drain → 200 { "workerId": "3f81a2c4", "drained": false }. Both are 404 UNKNOWN_WORKER for an id the gateway does
not know: draining is an instruction about a specific machine, and silently
succeeding against one that is not there would hide a typo in exactly the
situation — taking a machine out of service — where an operator most needs to
know the instruction landed.
DELETE /v1/workers/{id} → 200 { "workerId": "3f81a2c4", "removed": true },
or 409 WORKER_CONNECTED when its uplink is still open — a connected worker
would simply reappear, so drain it and stop it (or revoke its join token)
first. Unknown ids are the one place remove differs: 200 { "removed": false }, not 404, because "forget this worker" is already true of a worker the
gateway has already forgotten — the same reading token.revoke gives an
unknown token id.
On a single host (a daemon in worker mode), GET /v1/workers returns
one view, the host itself, with the fields a gateway shows for each of its
workers except catalogReadAt, which is a gateway's record of reading a
worker and is always absent here. id is the id the host presents to a gateway, label is its
gateway.label (absent when unset), connection is connected, drained
is false, and lastSeenAt is the time of the request. Every other field
comes from the host's own status, devices, catalog and config. Status and
devices are read on every request. The catalog is read again every 30
seconds, and straight after a component is installed, as a gateway reads its
workers' catalogs; a removed component leaves it on the next read. A host that
has joined a gateway still answers about itself: its drained is false
even when the gateway has drained it, because that flag is the gateway's.
The drain, undrain and remove routes answer 501 UNSUPPORTED_IN_WORKER_MODE
on a single host, with operation in the body: there is no gateway there to
take the host out of rotation or forget it.
Role: operator for all six. GET /v1/leases also answers an agent
token, with only its own leases. GET /v1/lease-requests and GET /v1/tokens
answer it 403 FORBIDDEN.
DELETE /v1/leases/{id} with an operator token already releases any single
lease, on a gateway as anywhere else. The fleet-wide form of that — the CLI's
simlock release --all — releases only gateway-issued leases, on every
connected worker, and never a worker's own local leases: the gateway did not
issue those, does not know who is holding them, and taking a local
developer's device away from an endpoint they have never heard of is not an
operator action anyone asked for. A worker it cannot reach is reported as
WORKER_UNREACHABLE naming that worker, and the leases on the workers it
could reach are still released — a partial result, said plainly, rather than
an all-or-nothing that leaves the operator guessing.
-
GET /v1/leases— every active lease (simlock list --leases). -
GET /v1/lease-requests— every request waiting for a device (simlock list --requests); see below. -
GET /v1/devices— every managed device, with state,transitionAgeMsandstalledasGET /v1/statusdescribes them (simlock list --devices). -
GET /v1/events?since=<duration>— replay business events newer thansince(simlock events --since). They come from the daemon's event file, so they include events from before a daemon restart and beyond the 1000 held in memory, back to the oldest event the file still holds (eventLog.rotateBytes). Withoutsince, the recent events held in memory. Every event carries anid, the same one after a daemon restart; events written before the upgrade that added it are not returned. -
GET /v1/events/stream— Server-Sent Events follow of the event bus (simlock events --follow). Each event carries the sameidas inGET /v1/events. -
GET /v1/tokens— every token this daemon knows (simlock token list):{ "tokens": [ { "id": "tok_9f2c", "role": "agent", "label": "ci-runner-3", "createdAt": 1735689600000 } ] }A token's
idis therequesterIdon the leases it takes over HTTP, so a client can show a lease's holder by the token'slabel. The answer never carries a secret or a hash.labelis absent from a token created without one.
On a gateway the first five are fleet-wide, which is what makes a single
console possible: /v1/leases and /v1/devices return every connected
worker's, each record carrying the workerId it lives on,
/v1/lease-requests returns the gateway's queue and every connected
worker's, and the two event routes carry the workers' republished events
(also workerId-tagged) interleaved with the gateway's own worker.* and
request.dispatched facts. /v1/tokens lists the daemon's own tokens: on a
gateway, the gateway's, since tokens never cross machines.
Every request still waiting for a device, oldest first, whoever sent it. An
agent token gets 403 FORBIDDEN.
{ "requests": [
{ "id": "req_7", "requesterId": "agent-b",
"spec": { "platform": "ios", "model": "iPhone 16", "osVersion": "18.4", "mode": "slim" },
"createdAt": 1790864071200, "stage": "queued", "queuePosition": 1 },
{ "id": "req_9", "requesterId": "local-agent",
"spec": { "platform": "android", "model": "Pixel 8" },
"createdAt": 1790864075000, "stage": "starting", "workerId": "3f81a2c4" }
] }specis the device as the request named it:platformandmodel, andosVersion,modeandimageTagonly when the request named them.createdAtis when the daemon holding the request received it, in milliseconds since the epoch.stageisqueuedwhile the request holds a place in the queue, andstartingwhile the daemon is working on it: placing it as it arrives, or finding, creating, booting or downloading a device for it.queuePositionis set only whilequeued: its place in the queue counting from 1, the requests ahead of it that are already starting included. It is the request's place now, not the one itsqueuedprogress first reported.- A request leaves the list as soon as it is granted, fails or is cancelled. The request's idempotency key and owner are never listed.
On a gateway the list is the gateway's own queue first, without
workerId, then the requests waiting in each connected worker's own queue,
from that worker's local agents, each with the workerId whose queue it
is in. A request the gateway has sent to a worker is listed once, as the
gateway's own. A worker's createdAt comes from that worker's clock.
Every failure is the same shape the daemon protocol uses:
{ "error": { "code": "NO_CAPACITY", "message": "..." } }| HTTP | Codes |
|---|---|
| 400 | BAD_REQUEST (malformed body, bad query param, validation) |
| 401 | UNAUTHENTICATED (missing or unrecognized token) |
| 403 | FORBIDDEN (role doesn't permit the route — including a worker token on any /v1 route other than /v1/uplink, and an agent/operator token at /v1/uplink; a /v1/lease-requests/* route whose request another token sent; or POST /v1/leases/{id}/renew/DELETE /v1/leases/{id}/POST /v1/leases/{id}/exec naming another requester's still-live lease), DOWNLOADS_DISABLED (POST /v1/components/install under downloads.policy: "never") |
| 404 | UNKNOWN_WORKER (POST/DELETE /v1/workers/{id}/drain naming a worker the gateway does not know), UNKNOWN_LEASE_REQUEST (unknown request id), UNKNOWN_LEASE (unknown lease id, expired/released, or GET /v1/leases/{id}/GET /v1/leases/{id}/events naming another requester's lease — see GET /v1/leases/{id}) |
| 409 | REQUESTER_ALREADY_LEASED (body names the existing lease id; fleet-wide on a gateway), IDEMPOTENCY_CONFLICT (an Idempotency-Key repeated with a different device), REQUEST_NOT_CANCELLABLE (body names the lease id if the request had already been granted), WORKER_CONNECTED (DELETE /v1/workers/{id} while its uplink is open), COMPONENT_NOT_OWNED, COMPONENT_IN_USE (body carries devices and foreignDevices), COMPONENT_BUSY (the three refusals of DELETE /v1/components/{platform}/{version}) |
| 422 | UNKNOWN_MODEL, RUNTIME_MISSING, NO_DRIVER, PASSTHROUGH_REFUSED (a refused exec verb, a caller-supplied --set/-P, a bare adb shell), UNKNOWN_PASSTHROUGH_TOOL |
| 501 | UNSUPPORTED_IN_GATEWAY_MODE (an operation that acts on one machine, asked of a gateway: POST /v1/components/install, GET /v1/components, DELETE /v1/components/{platform}/{version}), UNSUPPORTED_IN_WORKER_MODE (an operation on a gateway's workers, asked of a single host: POST/DELETE /v1/workers/{id}/drain, DELETE /v1/workers/{id}, POST /v1/components/install with workers) |
| 503 | NO_CAPACITY (with noWait: true, or, on a gateway, when no worker that takes requests can serve the request; response carries Retry-After), WORKER_UNREACHABLE (a gateway could not reach the worker holding this lease or request) |
| 504 | EXEC_TIMEOUT (a device.exec command outlived exec.timeoutMs), DOWNLOAD_TIMEOUT (a runtime download, waiting for another download included, outlived downloads.timeoutMs) |
Four notes on these codes.
WORKER_UNREACHABLE sits on 503 with NO_CAPACITY rather than on 502,
because its kind is transport and every other transport-kind code in
the contract's table (DAEMON_STOPPING, DAEMON_STARTUP_FAILED,
DAEMON_CONNECTION_LOST) is already a 503. "Try again shortly, the thing
behind this is not reachable right now" is the same answer in all four cases,
and a client with one retry rule for transport should not need a second one
because the unreachable thing happened to be a worker.
UNSUPPORTED_IN_GATEWAY_MODE comes from three routes in this version,
POST /v1/components/install without workers, GET /v1/components and
DELETE /v1/components/{platform}/{version} asked of a gateway — nuke,
cleanup, and
doctor are absent from the HTTP surface (see
Not implemented), and the status is fixed so adding
POST /v1/doctor or POST /v1/cleanup later is additive rather than a fresh
decision. 501 is the honest status for it: this is not a temporary
condition to retry past, it is an operation this daemon will never perform,
and nuke/cleanup/doctor/driver.passthrough and component listings
and removals stay per-worker permanently rather than pending some later
fan-out. A component install through a gateway is a different request, one
that names its workers.
UNSUPPORTED_IN_WORKER_MODE is the same refusal from the other side: the
operation acts on a gateway's workers, and the daemon is a single host. It
is permanent for that daemon, so do not retry it. Both codes carry
operation in the body.
EXEC_TIMEOUT's 504 is documented for completeness rather than for the
exec route: POST /v1/leases/{id}/exec has already answered 200 and begun
streaming by the time a command can outlive exec.timeoutMs, so on that
route it arrives as the stream's terminal error event — as does a
WORKER_UNREACHABLE that only happens mid-stream. The status is what a
client mapping the code without a route in front of it should use.
404 also covers UNKNOWN_WORKER (body names the workerId) on the worker
routes. Where an error carries typed details, they are inlined beside code
and message — details are contract, message text is not.
- Daemon restart. Lease requests are stored, so a request id from before
the restart still answers
GET. A request that already had its result keeps it. A request that was still waiting when the daemon stopped ends asfailed, with a message saying the daemon restarted: no wait survives a restart. Repeating it under the sameIdempotency-Keyreturns that same failure, so send a new key to ask again. - Idempotency keys are stored with their request. A repeat inside
lease.requestRetentionMsof the request finishing gets the stored request back, across a restart too. After that window the record is removed and the same key starts a new request. - Startup. The HTTP listener now starts the moment the daemon
claims its socket — the same instant the unix socket itself starts
accepting connections, before startup convergence (
doctor.reconcile(), running-capacity convergence) has run (bug fix, 0.3.0: it used to start only once convergence had already finished, so it could not observe or need this). A request that arrives before convergence completes now waits on the shared dispatcher's readiness gate exactly like a socket request, instead of being refused — every route but the routes that don't dispatch at all (GET /v1/healthz) can block briefly on a cold start. - Gateway restart, and a worker that goes away. A gateway keeps its lease
requests in memory only, so a gateway restart loses them: their ids answer
404 UNKNOWN_LEASE_REQUEST, and a repeat under the sameIdempotency-Keyis treated as a new request. Leases survive it, on their workers, which reconnect on their own backoff and let the gateway rebuild every view and its lease index. So a client that repeats its request after a gateway restart gets409 REQUESTER_ALREADY_LEASEDnaming the lease if one was granted —GETthat lease to recover it — or a new request if not. While a worker's uplink is down, anything routed to it is503 WORKER_UNREACHABLE; the gateway never reports a lease as gone before the worker says so, and the lease meanwhile runs out its TTL on the worker's own clock. - Shutdown.
simlock daemon stopcloses the HTTP listener (and any open connection, in-flight SSE streams included) before tearing down the lease engine, so no HTTP request can run against a stopping daemon. Stopping the daemon does not release anything: leases persist, and the next daemon restores each one's TTL timer from its deadline. A lease whose deadline passed while no daemon was running expires as soon as one is.
POST /v1/doctorandPOST /v1/cleanup— not part of this version; may land as a follow-up.nukeis absent from the HTTP surface entirely, deliberately: a remote fleet-wipe endpoint is a footgun even behind auth. It stays SSH/local-only (simlock nuke).dataPlaneon the lease object is reserved and alwaysnull— a byte-heavy data plane (live screen streaming, port forwarding, an interactive TTY) is tracked separately, not in this version. Running a command on the leased device is not waiting on it: that isPOST /v1/leases/{id}/exec, which works over HTTP against a lone worker and through a gateway alike.- File transfer for
exec. A command that names a path resolves it on the machine that owns the device; there is no upload route in this version, and the seam for a laterdevice.uploadis left open by design. - MCP-over-HTTP and in-process TLS are out of scope for this version too.
Multi-host brokering is no longer on this list: it is designed as gateway and
worker modes, and its routes (
/v1/uplink,/v1/workers*) are documented above.