HTTP API: expose the simlock control plane over the network
Why
Simlock today is reachable only over a unix socket on the machine it runs on. To let remote agents (e.g. Claude Code sessions on other machines) lease devices from a self-hosted simlock host, the control plane needs a network-facing API: request a device, watch the acquisition progress, renew, release, and observe the pool — over HTTP, behind real authentication.
This issue covers the control plane only. Driving the leased device remotely (the data plane) is a separate concern tracked in the agent-device integration issue.
Design principles
- Detached-style leases only. HTTP is stateless; "held lease = live connection" does not survive real networks. Remote leases are TTL-bound and kept alive by explicit renewal — the machinery detached mode already has. No WebSocket held-mode emulation.
- Acquisition is an async resource. A lease can take minutes to arrive (queue → provision → boot).
POST creates a lease request resource; the client polls, long-polls, or streams its progress. No long-blocking POST.
- Thin adapter, zero core semantics. The HTTP layer is a sibling frontend to the CLI/MCP: it depends on the same role interfaces (
LeaseCommands, QueueControl, CapacityReader, CatalogReader) and the event bus. The core never knows HTTP exists.
- Identity comes from the credential. The bearer token maps to a
requesterId server-side. SIMLOCK_AGENT_ID stays a local-frontend concern; over HTTP, identity is not client-declared.
- No in-process TLS in v1. Bind
127.0.0.1 by default; reaching the API remotely is the operator's tunnel (Tailscale, cloudflared, reverse proxy). Authorization is still required even on loopback.
Configuration
New http section (default off):
{
"http": {
"enabled": false,
"host": "127.0.0.1",
"port": 4700
}
}
Authentication
Authorization: Bearer slk_<secret> on every /v1/* route except GET /v1/healthz.
- Token store:
~/.simlock/tokens.json (under SIMLOCK_HOME), holding SHA-256 hashes of secrets — never plaintext — plus per-token metadata: { id, hash, role, requesterId, label, createdAt }.
- Roles:
agent (catalog, own lease requests/leases, status) and operator (agent + list all leases/devices, events, doctor, cleanup, release any lease).
- One token = one requester identity. The one-lease-per-agent rule keys off it.
- Minted and managed via a new CLI command:
simlock token create --role agent|operator [--label <text>] # prints the secret once
simlock token list
simlock token revoke <token-id>
Error model
Same shape and codes as the daemon protocol:
{ "error": { "code": "NO_CAPACITY", "message": "..." } }
| HTTP |
Codes |
| 400 |
USAGE, BAD_REQUEST (malformed body / validation) |
| 401 |
UNAUTHENTICATED (missing/unknown token) |
| 403 |
FORBIDDEN (role does not permit; or lease/request owned by another requester) |
| 404 |
UNKNOWN_LEASE, UNKNOWN_REQUEST |
| 409 |
REQUESTER_ALREADY_LEASED (body names the existing lease id), REQUEST_NOT_CANCELLABLE |
| 422 |
UNKNOWN_MODEL, RUNTIME_MISSING, NO_DRIVER |
| 503 |
NO_CAPACITY (only with noWait: true; includes Retry-After), DAEMON_STARTUP_FAILED |
API specification
All routes under /v1. JSON bodies. Additive evolution only.
GET /v1/healthz
Unauthenticated liveness for tunnels/load balancers. → 200 {"ok":true}.
GET /v1/status
status --json equivalent: daemon health (starting/running), managed/running capacity per platform, queue depth. Role: agent.
GET /v1/catalog?platform=ios|android
Exactly catalog --json. Role: agent. Read-only, never downloads.
POST /v1/lease-requests
Enqueue a device request. Role: agent. Supports the Idempotency-Key header: replaying the same key for the same requester returns the original request resource instead of double-queueing.
Request body:
{
"platform": "ios",
"device": "iPhone 17 Pro",
"os": "26.5",
"ttlMs": 900000,
"timeoutMs": 300000,
"noWait": false,
"allowDownload": false
}
platform and device required; os defaults to newest installed runtime; ttlMs defaults to lease.detachedTtlMs; timeoutMs (optional) is enforced daemon-side so a vanished client cannot hold a queue slot.
→ 201, Location: /v1/lease-requests/{id}:
{ "request": { "id": "req_7d1a", "state": "queued", "queuePosition": 2, "createdAt": "2026-09-01T09:12:00Z" } }
Errors: 409 REQUESTER_ALREADY_LEASED, 422 for unknown model / missing runtime / no driver, 503 NO_CAPACITY when noWait.
GET /v1/lease-requests/{id}
Poll the request. ?wait=<seconds> long-polls: returns early on any state change, else after the wait. Role: agent (own requests only; operator sees all).
States mirror the existing LeaseProgress stages plus terminals:
queued | reclaiming | provisioning | booting | granted | failed | cancelled, with queuePosition / etaSeconds where the stage carries them.
Terminal granted embeds the lease object (below). Terminal failed embeds the error object (e.g. QUEUE_TIMEOUT).
GET /v1/lease-requests/{id}/events
Server-Sent Events stream of the same progress objects, one event per state change, ending with granted or failed. Periodic : keepalive comments (~15s) so idle tunnels don't close the stream.
DELETE /v1/lease-requests/{id}
Cancel. → 204 if the request was still cancellable; 409 REQUEST_NOT_CANCELLABLE once device work for it is in flight or it already reached a terminal state (body says which; if granted, it names the lease id — release that instead). 404 unknown.
Lease object
{ "lease": {
"id": "lse_9f2c", "requestId": "req_7d1a",
"platform": "ios", "device": "iPhone 17 Pro", "os": "26.5",
"udid": "ABCD-...", "deviceId": "dev_1a2b",
"createdAt": "2026-09-01T09:14:07Z",
"expiresAt": "2026-09-01T09:29:07Z", "ttlMs": 900000,
"dataPlane": null
} }
dataPlane is reserved in v1 (always null): the agent-device integration issue populates it with a connection bundle (kind, baseUrl, credential, device selector). It is in the schema now so its arrival is additive.
GET /v1/leases/{id}
Re-fetch the lease (a client that restarts mid-lease recovers its state instead of leaking the lease). Role: agent (own lease; operator any). 404 UNKNOWN_LEASE once expired/released.
POST /v1/leases/{id}/renew
Body { "ttlMs": 900000 } (optional; defaults to the lease's mode default). Resets the deadline to now + ttl — existing renew semantics.
→ 200 { "leaseId": "lse_9f2c", "expiresAt": "...", "notices": [] }
notices carries device-health facts observed since the previous renew for this lease — {"event":"device_unhealthy"}, {"event":"device_recovered","attempts":1} — so a polling-only client hears its device blinked without holding a stream. Role: agent (own lease).
GET /v1/leases/{id}/events
SSE for live health pushes on this lease: device_unhealthy, device_recovered, lease_lost (lease_lost ends the stream). Same facts held mode relays on stderr today.
DELETE /v1/leases/{id}
Release. → 202 { "released": true, "device": { "id": "dev_1a2b", "state": "reclaiming" } } — the lease is gone now; the purge continues in the background (existing release semantics; hence 202, not 200). Role: agent (own lease); operator may release any.
Operator surface
GET /v1/leases — all active leases (the list --leases view).
GET /v1/devices — all managed devices with states and transitionAgeMs (the list --devices view).
GET /v1/events?since=<duration> — replay from the business-event ring buffer.
GET /v1/events/stream — SSE follow of the event bus.
POST /v1/doctor { "fix": false } and POST /v1/cleanup { "dryRun": true, "rule": "idle-destroy" } — may land as a follow-up; not required for v1.
Deliberately absent: nuke stays off HTTP entirely (SSH-only). A remote fleet-wipe endpoint is a footgun even behind auth.
Lifecycle semantics
- Daemon restart: in-flight lease requests are in-memory and do not survive (same as today's queue). Clients get
404 on their request id and re-request; if their grant landed before the crash, the persisted detached lease answers the retry with 409 REQUESTER_ALREADY_LEASED naming the lease id, which the client then GETs. Document this recovery loop for provider implementations.
- Idempotency keys are in-memory with a TTL; after a restart a replay creates a fresh request (the 409 backstop prevents double-grants).
- Expiry: a remote lease that stops renewing expires via the existing TTL machinery; the device is reclaimed normally.
Implementation notes
- Stack:
hono + @hono/node-server + @hono/zod-validator (zod is already a dependency). The Hono app is a pure Request → Response function — no node:http import outside the serve adapter, keeping the ports rule intact. Unit tests drive the full stack via app.request() with fakes; no sockets.
- Module layout:
src/http/ — app.ts (createHttpApp(deps)), auth.ts (bearer middleware over a TokenVerifier), tracker.ts (lease-request resource state), sse.ts (event-bus → SSE bridge), server.ts (the only impure file: serve() wiring). Wired in startDaemon behind http.enabled.
LeaseRequestTracker: gateway-layer component that calls LeaseCommands.request() with an onProgress callback, assigns request ids (IdGenerator), records the current stage, and lets GET/long-poll/SSE observe it. No core changes for observation.
- Core addition — per-request cancel: the queue has
cancelAll (nuke) and timeout rejection but no single-request cancel. Add one, modelled exactly on the queue-timeout rejection path (same safety envelope: cancellation follows whatever timeout expiry already tolerates). Cancel succeeds only while no device work is in flight for the waiter; otherwise REQUEST_NOT_CANCELLABLE.
- Renew
notices: subscribe the tracker (or a lease-notice buffer) to the health monitor's existing lease-scoped pushes; drain per lease on each renew.
- Time: all timers (long-poll, SSE keepalive, idempotency TTL) go through the
Clock port; no Date.now()/setTimeout in logic.
- Logging: module-scoped child logger (
logger.child("http")), one structured line per request outcome — method, path, status, requesterId, duration.
Testing
- Unit:
createHttpApp with fake role interfaces, manually-advanced Clock, in-memory token store — full route coverage including auth, ownership (403 on another requester's lease), error mapping, long-poll early-return, SSE event framing, idempotency replay, cancel-vs-in-flight.
- E2E: daemon started with
http.enabled on an ephemeral port against the fake driver (SIMLOCK_DRIVERS_MODULE), driven with plain fetch: full loop token create → POST lease-request → SSE to granted → renew → DELETE lease, plus restart-recovery (404 → re-request → 409 → GET lease).
Out of scope (tracked elsewhere or deferred)
- Populating
dataPlane and everything agent-device-facing → agent-device integration issue.
- MCP over HTTP; in-process TLS; multi-host brokering (per-lease
dataPlane.baseUrl already leaves room); nuke over HTTP; live screen streaming.
HTTP API: expose the simlock control plane over the network
Why
Simlock today is reachable only over a unix socket on the machine it runs on. To let remote agents (e.g. Claude Code sessions on other machines) lease devices from a self-hosted simlock host, the control plane needs a network-facing API: request a device, watch the acquisition progress, renew, release, and observe the pool — over HTTP, behind real authentication.
This issue covers the control plane only. Driving the leased device remotely (the data plane) is a separate concern tracked in the agent-device integration issue.
Design principles
POSTcreates a lease request resource; the client polls, long-polls, or streams its progress. No long-blocking POST.LeaseCommands,QueueControl,CapacityReader,CatalogReader) and the event bus. The core never knows HTTP exists.requesterIdserver-side.SIMLOCK_AGENT_IDstays a local-frontend concern; over HTTP, identity is not client-declared.127.0.0.1by default; reaching the API remotely is the operator's tunnel (Tailscale, cloudflared, reverse proxy).Authorizationis still required even on loopback.Configuration
New
httpsection (default off):{ "http": { "enabled": false, "host": "127.0.0.1", "port": 4700 } }Authentication
Authorization: Bearer slk_<secret>on every/v1/*route exceptGET /v1/healthz.~/.simlock/tokens.json(underSIMLOCK_HOME), holding SHA-256 hashes of secrets — never plaintext — plus per-token metadata:{ id, hash, role, requesterId, label, createdAt }.agent(catalog, own lease requests/leases, status) andoperator(agent + list all leases/devices, events, doctor, cleanup, release any lease).Error model
Same shape and codes as the daemon protocol:
{ "error": { "code": "NO_CAPACITY", "message": "..." } }USAGE,BAD_REQUEST(malformed body / validation)UNAUTHENTICATED(missing/unknown token)FORBIDDEN(role does not permit; or lease/request owned by another requester)UNKNOWN_LEASE,UNKNOWN_REQUESTREQUESTER_ALREADY_LEASED(body names the existing lease id),REQUEST_NOT_CANCELLABLEUNKNOWN_MODEL,RUNTIME_MISSING,NO_DRIVERNO_CAPACITY(only withnoWait: true; includesRetry-After),DAEMON_STARTUP_FAILEDAPI specification
All routes under
/v1. JSON bodies. Additive evolution only.GET /v1/healthzUnauthenticated liveness for tunnels/load balancers. →
200 {"ok":true}.GET /v1/statusstatus --jsonequivalent: daemon health (starting/running), managed/running capacity per platform, queue depth. Role: agent.GET /v1/catalog?platform=ios|androidExactly
catalog --json. Role: agent. Read-only, never downloads.POST /v1/lease-requestsEnqueue a device request. Role: agent. Supports the
Idempotency-Keyheader: replaying the same key for the same requester returns the original request resource instead of double-queueing.Request body:
{ "platform": "ios", "device": "iPhone 17 Pro", "os": "26.5", "ttlMs": 900000, "timeoutMs": 300000, "noWait": false, "allowDownload": false }platformanddevicerequired;osdefaults to newest installed runtime;ttlMsdefaults tolease.detachedTtlMs;timeoutMs(optional) is enforced daemon-side so a vanished client cannot hold a queue slot.→
201,Location: /v1/lease-requests/{id}:{ "request": { "id": "req_7d1a", "state": "queued", "queuePosition": 2, "createdAt": "2026-09-01T09:12:00Z" } }Errors:
409 REQUESTER_ALREADY_LEASED,422for unknown model / missing runtime / no driver,503 NO_CAPACITYwhennoWait.GET /v1/lease-requests/{id}Poll the request.
?wait=<seconds>long-polls: returns early on any state change, else after the wait. Role: agent (own requests only; operator sees all).States mirror the existing
LeaseProgressstages plus terminals:queued | reclaiming | provisioning | booting | granted | failed | cancelled, withqueuePosition/etaSecondswhere the stage carries them.Terminal
grantedembeds the lease object (below). Terminalfailedembeds the error object (e.g.QUEUE_TIMEOUT).GET /v1/lease-requests/{id}/eventsServer-Sent Events stream of the same progress objects, one event per state change, ending with
grantedorfailed. Periodic: keepalivecomments (~15s) so idle tunnels don't close the stream.DELETE /v1/lease-requests/{id}Cancel. →
204if the request was still cancellable;409 REQUEST_NOT_CANCELLABLEonce device work for it is in flight or it already reached a terminal state (body says which; ifgranted, it names the lease id — release that instead).404unknown.Lease object
{ "lease": { "id": "lse_9f2c", "requestId": "req_7d1a", "platform": "ios", "device": "iPhone 17 Pro", "os": "26.5", "udid": "ABCD-...", "deviceId": "dev_1a2b", "createdAt": "2026-09-01T09:14:07Z", "expiresAt": "2026-09-01T09:29:07Z", "ttlMs": 900000, "dataPlane": null } }dataPlaneis reserved in v1 (alwaysnull): the agent-device integration issue populates it with a connection bundle (kind,baseUrl, credential, device selector). It is in the schema now so its arrival is additive.GET /v1/leases/{id}Re-fetch the lease (a client that restarts mid-lease recovers its state instead of leaking the lease). Role: agent (own lease; operator any).
404 UNKNOWN_LEASEonce expired/released.POST /v1/leases/{id}/renewBody
{ "ttlMs": 900000 }(optional; defaults to the lease's mode default). Resets the deadline to now + ttl — existing renew semantics.→
200 { "leaseId": "lse_9f2c", "expiresAt": "...", "notices": [] }noticescarries device-health facts observed since the previous renew for this lease —{"event":"device_unhealthy"},{"event":"device_recovered","attempts":1}— so a polling-only client hears its device blinked without holding a stream. Role: agent (own lease).GET /v1/leases/{id}/eventsSSE for live health pushes on this lease:
device_unhealthy,device_recovered,lease_lost(lease_lostends the stream). Same facts held mode relays on stderr today.DELETE /v1/leases/{id}Release. →
202 { "released": true, "device": { "id": "dev_1a2b", "state": "reclaiming" } }— the lease is gone now; the purge continues in the background (existing release semantics; hence 202, not 200). Role: agent (own lease); operator may release any.Operator surface
GET /v1/leases— all active leases (thelist --leasesview).GET /v1/devices— all managed devices with states andtransitionAgeMs(thelist --devicesview).GET /v1/events?since=<duration>— replay from the business-event ring buffer.GET /v1/events/stream— SSE follow of the event bus.POST /v1/doctor{ "fix": false }andPOST /v1/cleanup{ "dryRun": true, "rule": "idle-destroy" }— may land as a follow-up; not required for v1.Deliberately absent:
nukestays off HTTP entirely (SSH-only). A remote fleet-wipe endpoint is a footgun even behind auth.Lifecycle semantics
404on their request id and re-request; if their grant landed before the crash, the persisted detached lease answers the retry with409 REQUESTER_ALREADY_LEASEDnaming the lease id, which the client thenGETs. Document this recovery loop for provider implementations.Implementation notes
hono+@hono/node-server+@hono/zod-validator(zod is already a dependency). The Hono app is a pureRequest → Responsefunction — nonode:httpimport outside the serve adapter, keeping the ports rule intact. Unit tests drive the full stack viaapp.request()with fakes; no sockets.src/http/—app.ts(createHttpApp(deps)),auth.ts(bearer middleware over aTokenVerifier),tracker.ts(lease-request resource state),sse.ts(event-bus → SSE bridge),server.ts(the only impure file:serve()wiring). Wired instartDaemonbehindhttp.enabled.LeaseRequestTracker: gateway-layer component that callsLeaseCommands.request()with anonProgresscallback, assigns request ids (IdGenerator), records the current stage, and letsGET/long-poll/SSE observe it. No core changes for observation.cancelAll(nuke) and timeout rejection but no single-request cancel. Add one, modelled exactly on the queue-timeout rejection path (same safety envelope: cancellation follows whatever timeout expiry already tolerates). Cancel succeeds only while no device work is in flight for the waiter; otherwiseREQUEST_NOT_CANCELLABLE.notices: subscribe the tracker (or a lease-notice buffer) to the health monitor's existing lease-scoped pushes; drain per lease on each renew.Clockport; noDate.now()/setTimeoutin logic.logger.child("http")), one structured line per request outcome — method, path, status, requesterId, duration.Testing
createHttpAppwith fake role interfaces, manually-advancedClock, in-memory token store — full route coverage including auth, ownership (403 on another requester's lease), error mapping, long-poll early-return, SSE event framing, idempotency replay, cancel-vs-in-flight.http.enabledon an ephemeral port against the fake driver (SIMLOCK_DRIVERS_MODULE), driven with plainfetch: full looptoken create → POST lease-request → SSE to granted → renew → DELETE lease, plus restart-recovery (404→ re-request →409→GETlease).Out of scope (tracked elsewhere or deferred)
dataPlaneand everything agent-device-facing → agent-device integration issue.dataPlane.baseUrlalready leaves room);nukeover HTTP; live screen streaming.