API and streaming
The OpenAPI document at /openapi.json is the machine-readable HTTP contract. This guide describes behavior that matters to an agent integration.
Authentication
Send a bearer credential on every /v1/* request except a WebSocket upgrade authenticated with the one-use stream ticket described below:
Authorization: Bearer TENANT_KEYIndexed credentials and RFC 9068 JWTs resolve a principal, tenant, scopes, and authority lifetime; legacy keys map to one tenant with the complete compatibility scope set. Job identifiers are not authorization tokens. Job reads, cancellation, streams, result artifacts, and attestations always check tenant ownership, while route-specific middleware enforces jobs:submit, jobs:read, jobs:cancel, service:read, or metrics:read. Invalid credentials use RFC 6750 challenges. /healthz, /readyz, /openapi.json, and the dashboard shell are public by design, so do not put secrets in those responses.
Rookhold uses plain HTTP/1.1. Put it behind a TLS proxy for any connection that leaves the local host or a private encrypted network; the proxy may serve HTTP/2 or HTTP/3 to clients while using HTTP/1.1 on the private Rookhold hop.
Submit
POST /v1/jobs accepts:
{
"language": "python",
"code": "print('hello')",
"stdin": "optional input\n",
"files": [{"path": "input/data.csv", "content_base64": "YSxiXG4xLDJcbiJ9"}],
"outputs": ["output/report.json"],
"runtime": "python:bookworm-20260826-stdlib",
"requirements": {
"minimum_isolation": "linux-shared-kernel"
},
"limits": {
"wall_seconds": 15,
"cpu_seconds": 10,
"mem_mb": 256,
"max_pids": 128,
"max_file_mb": 16,
"allow_network": false
}
}The gVisor and namespace providers support python, node, and bash after their full rootfs/runtime startup preflight succeeds. Development mode probes each configured host runtime once at startup and advertises only the languages that pass the exact sanitized-environment canary in GET /v1/capabilities. Submitting a known but unavailable runtime returns 422 runtime_unavailable; an unknown language returns 400 unsupported_language. Omitted limits receive defaults and all client values are clamped to server ceilings. allow_network: true is rejected rather than granting egress. Isolated execution reports networking: "disabled"; a ready development subprocess truthfully reports its retained host networking as networking: "host".
requirements.minimum_isolation is checked atomically before a job is persisted and again before execution. The process-provider order is none < linux-shared-kernel < gvisor-application-kernel < hardware-vm < confidential-vm; a stronger observed class satisfies a weaker minimum. The wasm-capability class is a separate branch and satisfies only itself (or a minimum of none). An unsatisfied minimum returns 422 minimum_isolation_unsatisfied without creating a job.
Source and stdin are each capped at 1 MiB after JSON decoding. A job accepts at most 32 input files, 2 MiB per file and 4 MiB total, plus at most 32 requested outputs under the same per-file and total ceilings. Paths must be relative under input/ or output/; absolute paths, traversal, backslashes, duplicates, non-regular output files, and symlink traversal are rejected. The encoded request body is capped at 24 MiB so worst-case valid JSON escaping and base64 still fit without allowing unbounded buffering. Body reads have a 30-second deadline and global/per-tenant active-read caps; capacity failures are structured retryable 429/503 responses. Stored/emitted stdout and stderr are independently capped at 1 MiB and 10,000 records, with any single record split at 16 KiB. The executor continues draining after the storage cap so a noisy child cannot block supervision; the event history and receipt record truncation and observed byte counts.
A successful submission returns 201 Created with the job ID, initial status, relative stream/history URLs, Location: /v1/jobs/{id}, and an Idempotency-Replayed response header. For safe reconciliation after an ambiguous transport failure, send exactly one Idempotency-Key containing 1–128 visible ASCII bytes. Reusing that tenant-scoped key with the same canonical job spec returns the original job and Idempotency-Replayed: true; reusing it for a different spec returns 422 idempotency_key_reused. Queue or global lifetime-capacity saturation returns 503; tenant lifetime saturation returns 429. Callers should honor Retry-After and use bounded exponential backoff with jitter rather than retrying immediately.
Status and cancellation
GET /v1/jobs/{id} returns the lifecycle projection, requested spec, effective spec, execution policy, and (once terminal) receipt plus receipt_sha256. requested_spec remains the complete clamped-input record. Compare a non-null effective_spec—not the request—to understand what the selected backend actually enforced. Its EffectiveLimits members are individually nullable: the gVisor and namespace providers set all five resource controls after successful bootstrap, while the development subprocess sets only wall_seconds; its CPU, memory, process, and file values are null. limit_enforcement carries the corresponding explicit booleans.
Executor posture is published only from the backend's observed workload-ready boundary. A pre-ready workdir/helper/pivot/seccomp failure has bootstrap_ready: false, does not claim isolation/rootfs/seccomp/bootstrap, and has null effective controls and network posture. Queued, migrated, or restart-recovered rows with no executor observation return effective_spec: null and null execution-policy fields; the API does not fabricate historical posture from current configuration. Terminal statuses are:
succeededfailedtimed_outoom_killedcancellederror
DELETE /v1/jobs/{id} requests cancellation. It is idempotent and returns the current job projection plus cancellation_requested and already_terminal; repeating it for an already-terminal job remains 200. Cancellation is cooperative at the scheduler boundary and forceful at the executor boundary. Once terminal, a job does not return to a running state.
Waiting for a result
GET /v1/jobs/{id}/result?wait_seconds=60 is the preferred agent-tool endpoint. It waits up to the requested server-side budget (maximum 300 seconds) and folds output into a single object:
{
"job_id": "…",
"status": "succeeded",
"exit_code": 0,
"duration_ms": 42,
"stdout": "hello",
"stderr": "",
"truncated": false,
"violations": [],
"artifacts": [{
"path": "output/report.json",
"content_base64": "eyJvayI6dHJ1ZX0=",
"size_bytes": 11,
"sha256": "…"
}]
}Do not poll job status in a short loop. It creates unnecessary SQLite load and consumes the same tenant rate budget as submissions. If a wait expires, retry with backoff or switch to the stream. A wait interrupted by server shutdown returns retryable 503 shutting_down without constructing a potentially large partial result; retry the durable job after the service is ready again.
Detail, replay, and folded-result JSON use a capacity-one 64 KiB response pump under global/per-tenant lifetime admission. The response body owns that admission permit and the serialized buffer until EOF or connection teardown, so progressing clients receive the complete declared JSON even at low bandwidth. Every accepted connection has a 30-second write-progress deadline: if Hyper is trying to write response bytes and the socket accepts no bytes for that entire interval, the server closes the connection, drops the body, and reclaims its capacity. A positive socket write resets that deadline; the independent 10-minute absolute connection lifetime remains the outer bound.
The transport admits at most 256 connections, gives every HTTP/1 request head a total 30 seconds (including silent and partial-preface peers), and closes every connection after an absolute 10 minutes. These are compiled transport safety invariants and are logged at startup. The connection permit and absolute deadline move into an upgraded WebSocket; idle reads do not arm the write-progress timer, but the absolute lifetime still requires cursor-based reconnect. The request-head timer ends before a handler, /result wait, response transfer, or WebSocket session begins. Graceful shutdown first lets those upgraded sockets close normally; if the bounded HTTP drain expires, dropping the server force-closes guarded socket I/O and reclaims the global permit.
A response cut short by a transport boundary does not satisfy its declared Content-Length; clients must reject it as incomplete JSON. Detail, result, and replay are idempotent reads and may be retried with backoff from durable state. For replay, advance after only after a complete page has decoded and been accepted, then resume from that page's next_cursor; never advance a cursor from a truncated response. Do not apply this rule blindly to POST /v1/jobs: a submission transport failure does not prove that the durable job was not accepted. Prefer replay pagination when consuming a large event history.
Event history
GET /v1/jobs/{id}/replay?after=SEQ&limit=N returns an {events,next_cursor} page in sequence order. The cursor is exclusive. The endpoint name means replay stored events, not rerun the program. Consumers should preserve unknown event kinds and hash metadata for forward compatibility.
Common event kinds include lifecycle transitions, stdout, stderr, artifact, violation, truncated, and finished. Use the sequence field for ordering; do not infer order from client receive time.
Receipts and hash chains
Every newly written event carries hash_version: 1, prev_hash, and event_hash. The event digest covers a versioned domain separator, job ID, previous hash, sequence, timestamp, kind, and canonical JSON data. A migrated v0.1 event uses hash_version: 0; it is preserved but explicitly unverifiable.
The terminal receipt records code/stdin/policy hashes, requested and effective limits, backend/seccomp/network posture, private-rootfs and dedicated-bootstrap facts, lifecycle and outcome fields, resource observations when available, output evidence, input/output artifact hashes, the selected runtime-pack name, and the final event-chain metadata. bootstrap_ready is the executor-observed readiness bit. When it is false, the isolation facts are false, network_allowed/networking are null, effective_limits contains null values, and every limit_enforcement flag is false. When executor provenance is unknown, those execution-specific members are omitted entirely. output is durable evidence: its byte counts and SHA-256 values cover retained output event strings encoded as UTF-8 and joined with one LF between records and no trailing LF (encoding: "utf8-event-lines-joined-by-lf-no-trailing-lf"). truncated says at least one retained stream exceeded its configured boundary.
When executor telemetry survived, the optional top-level executor_output keeps raw pre-persistence observations separately for each stream: bytes_seen, bytes_offered_to_sink, records_offered_to_sink, raw_sha256, and executor_truncated. Offered records may exceed what became durable if the bounded sink failed or saturated, so do not compare those raw digests to output.*_sha256. resource_usage contains wall/CPU/memory observations only.
If startup recovery finds a job that was running when the server stopped, it emits a minimal terminal receipt with terminal_reason: "server_restarted", outcome/timing fields, the event-chain summary, and the receipt digest. Execution-only evidence such as effective limits, runtime posture, output hashes, and resource observations is unavailable for that interrupted run; clients must treat those members as optional rather than inventing zero values.
event_chain.complete means the terminal transaction saw no legacy rows and the count of v1 hashed rows equalled the stored event count. It is not an on-read cryptographic verification result; an auditor must still recompute every event link. receipt_sha256 is SHA-256 over the canonical receipt JSON with that member removed. These hashes are not signatures on their own; signed evidence is the separate convergent record below.
Signed attestations and result artifacts
Schema v4 commits an attestation-outbox row atomically with every terminal receipt. A bounded control-plane worker then builds a deterministic result artifact, signs an in-toto Statement/v1 inside an exact-byte DSSE envelope with Ed25519, self-verifies it, and conditionally persists it only if the receipt bytes still match. Signing is therefore crash-convergent rather than falsely described as atomic: immediately after terminalization, attestation.available may be false; clients that require a signature must wait boundedly.
GET /v1/jobs/{id} includes:
{
"attestation": {
"available": true,
"tenant": "TENANT_ID",
"key_id": "sha256:…",
"receipt_sha256": "…",
"result_media_type": "application/vnd.coop.execution-result.v1+json",
"result_sha256": "…",
"result_size_bytes": 321,
"envelope_sha256": "…",
"envelope_size_bytes": 2048,
"envelope_url": "/v1/jobs/JOB_ID/attestation",
"result_artifact_url": "/v1/jobs/JOB_ID/result-artifact"
}
}attestation.tenant is the tenant claim bound into the persisted evidence; it is null while evidence is unavailable. The two authenticated download routes return the exact stored bytes, with their media type, Content-Length, and X-Content-Sha256. The result artifact is canonical JSON containing _type, schema_version: 1, authoritative tenant, lifecycle/outcome, receipt digest, folded stdout/stderr, truncation, and violations. The signed predicate carries the same tenant. Existing v0.3 receipts remain embedded unchanged and may omit tenant; backfill obtains it from the durable job row instead. The result artifact is capped at 16 MiB; the DSSE envelope is capped at 2 MiB. These records share the same tenant authorization and large-response lifetime limits as job detail. On upgrade, pre-fix persisted files that do not carry the same tenant/job/ receipt/digest links are removed from availability and requeued; job detail never synthesizes a tenant claim for those stale bytes.
GET /v1/attestation/public-key returns the current canonical SPKI PEM and key ID when signing is enabled. Its trust_notice is part of the contract: the unauthenticated DSSE keyid hint and a key fetched from the signer are not independent trust anchors. Pin or distribute the public key out of band, retain old keys across rotation, and verify the envelope plus exact result with rookhold-verify verify --tenant EXPECTED_TENANT. Verification proves possession of that key and profile integrity; it does not prove trusted hardware, deterministic execution, or semantic truth. ROOKHOLD_ATTESTATION_MODE=off is an explicit production policy that produces no signatures.
WebSocket stream
First send an authenticated POST /v1/jobs/{id}/stream-ticket. It returns a short-lived, one-use, job-bound ticket and stream URL. Open that URL with ws:// (or wss:// behind TLS). The server consumes the ticket before active-stream admission, sends persisted history first, then live events, and finishes after the terminal event. A 429/503 capacity rejection therefore requires minting a new ticket after Retry-After. Reconnect by minting a new ticket and continue from the last sequence; deduplicate by job ID and sequence number.
Browser WebSocket APIs cannot set an Authorization header. Stream tickets exist so the long-lived API key never needs to enter a WebSocket URL. The bundled clients disable their v0.1 API-key query fallback by default; enabling it requires an explicit opt-in and should be limited to a trusted legacy server because URLs leak into history, logs, and proxy telemetry. Structured Rookhold errors never trigger the fallback.
Clients must handle:
- connection establishment, live, reconnecting, and closed states
- a job becoming terminal between history replay and live subscription
- lag recovery and repeated frames
- output truncation
- credential rotation closing an existing connection
Listing and metrics
GET /v1/jobs?limit=N&cursor=CURSOR&status=STATUS&language=LANGUAGE returns {items,next_cursor} for the authenticated tenant. Cursors are opaque; pass them through unchanged.
GET /v1/capabilities describes supported languages, the provider's isolation_class, per-job and artifact ceilings, the aggregate concurrent_mem_mb_max, and versioned runtime_packs available for an isolated provider. It also reports server features and signer metadata when enabled. GET /v1/whoami resolves the authenticated tenant, principal, credential/auth method, scopes, and authority expiry. GET /v1/metrics exposes tenant-scoped current-job metrics. The separate global /metrics endpoint is disabled unless ROOKHOLD_METRICS_TOKEN is configured and never accepts a tenant credential; it uses fixed-cardinality labels and OpenMetrics negotiation. Neither surface is a billing ledger. /healthz is liveness and /readyz checks cached process/store readiness. Use authenticated /v1/status, signer capabilities, and an actual minimum-isolation plus signed-result canary before traffic.
Errors and rate limits
Errors use a stable JSON envelope and repeat the request ID in X-Request-Id:
{
"error": {
"code": "rate_limited",
"message": "request rate limit exceeded",
"request_id": "…",
"retryable": true
}
}Treat HTTP status codes and error codes as authoritative. In particular:
400invalid request or unsupported language401missing/invalid key404missing job or foreign-tenant job409invalid lifecycle operation422unavailable runtime, unsatisfied minimum isolation, or an idempotency key reused for a different request429tenant rate budget or per-tenant body/result-wait/stream/response lifetime capacity exhausted503admission queue, bounded request/response lifetime capacity, shutdown, or worker service unavailable507filesystem free-space reserve prevents durable admission
Honor Retry-After when present. Retry reads automatically only when their operation policy allows it. Retry an ambiguously acknowledged submission only when it carried an Idempotency-Key, and reuse the exact key and job spec.