API documentation
One REST API for everything Roundhouse does: submit jobs with priorities and deadlines, keep your Bedrock Converse code with a drop-in synchronous endpoint, and watch every routing decision and metered token stream back in real time.
One base URL, JSON in and out
Everything lives under a single versioned base URL:
https://roundhouseworks.com/api/v1
Requests and responses are JSON. Every resource is scoped to your organization — an API key can only ever see its own org's jobs, workers, and events. The surface is additive-only: fields may be added to responses, but nothing documented here will be renamed or removed within v1.
The native model is asynchronous — submit a job, let the router place it, fetch the result — because that is what makes deadline-aware placement possible. If you need a synchronous call today, start at the Bedrock-compatible endpoint.
Roundhouse is in private beta. API keys are issued with beta access, invitation only. Join the waitlist and tell us about your workloads — that's what gets you invited sooner.
Bearer API keys, scoped per org
Authenticate every request with an org-scoped API key (keys look like dsk_…) in the Authorization header. Each key carries one or more scopes:
| Scope | Grants |
|---|---|
jobs:read | List jobs, fetch results, read event feeds |
jobs:write | Submit and cancel jobs, call the Converse endpoint |
worker | Register workers, claim and complete work |
admin | Administer the org |
Rate limits are generous — 600 requests per minute per key — and enforced with a 429 plus a Retry-After header when exceeded (see Errors).
# the only header Roundhouse needs
curl "https://api.roundhouseworks.com/api/v1/jobs" \
-H "Authorization: Bearer dsk_your_api_key"
Submit work with a priority and a deadline
- POST /api/v1/jobs Submit a job
- GET /api/v1/jobs List jobs (cursor-paginated, filterable)
- GET /api/v1/jobs/{job_id} Full job record, payload and result included
- GET /api/v1/jobs/{job_id}/events Per-job audit trail
- GET /api/v1/jobs/{job_id}/result Just the result
- GET /api/v1/jobs/{job_id}/routing Why it ran where it ran
- POST /api/v1/jobs/{job_id}/cancel Cancel a non-terminal job
Submitting a job
A submission is a payload plus three routing facts — how good the model must be, how urgent the job is, and when it must be done:
| Field | Meaning |
|---|---|
payload | Your job body, any non-empty JSON. Required. |
required_capability | Capability tier the job needs: tier-basic, tier-standard, or tier-frontier. Required. |
priority | 1–10 (10 highest). Default 5. Breaks ties in the queue. |
deadline_at | ISO 8601. Drives placement: local when your hardware can finish in time, cloud escalation when it can't. Optional. |
idempotency_key | Any string up to 200 chars, unique per org. Re-sending the same key returns 200 with the original job instead of creating a duplicate — safe to retry submissions blindly. |
tags | List of strings for filtering and reporting. |
estimated_output_tokens | Optional hint (0–1,000,000) that sharpens the router's completion-time and cost estimates. |
Also accepted: earliest_start_at, max_retries (0–10, default 3), and allow_overspend to let a job escalate past the org's daily cloud budget cap when it truly must run.
curl -X POST "https://api.roundhouseworks.com/api/v1/jobs" \
-H "Authorization: Bearer dsk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"payload": {"prompt": "Summarize this incident report."},
"required_capability": "tier-standard",
"priority": 7,
"deadline_at": "2026-08-20T18:00:00Z",
"idempotency_key": "incident-8412",
"tags": ["incident"]
}'
# 201 Created (200 on idempotent replay)
{
"id": "0198f6a2-6b1e-7c3d-9a10-3f2e8c11d204",
"status": "pending",
"priority": 7,
"required_capability": "tier-standard",
"deadline_at": "2026-08-20T18:00:00Z",
"earliest_start_at": null,
"idempotency_key": "incident-8412",
"max_retries": 3,
"attempt_count": 0,
"retries_remaining": 3,
"tags": ["incident"],
"allow_overspend": false,
"cost_estimate": "0.000000",
"estimated_input_tokens": 0,
"estimated_output_tokens": 0,
"routing_decision": {},
"error": null,
"is_overdue": false,
"payload_bytes": 45,
"started_at": null,
"finished_at": null,
"created_at": "2026-08-20T17:12:03.412809+00:00",
"updated_at": "2026-08-20T17:12:03.412809+00:00"
}
Listing and filtering
GET /api/v1/jobs is cursor-paginated — stable under insert load, unlike offsets. Pass cursor from the previous page's next link and page_size (default 50, max 200). Filter with status (repeatable), tag, executor (local_worker, bedrock, anthropic), and a free-text q.
The job lifecycle
A job moves through a small, fully audited state machine. The router places it in one of two lanes; the escalation sweep re-checks queued local work every 30 seconds and moves at-risk jobs to the cloud lane before their deadlines slip.
- Failures retry with backoff up to
max_retries, then land indead_letterfor inspection. heldmeans the daily cloud budget cap was reached — the job waits for local capacity or the next budget window rather than silently overspending.- Every transition is an event you can read on the feed or the per-job audit trail.
┌─▶ queued_local ─▶ leased ─▶ running ─┐ pending ──┤ │ escalation sweep (30s) ├─▶ succeeded └─▶ queued_cloud ─────────▶ running ─┘ failed ──▶ retried with backoff, up to max_retries ──▶ dead_letter when retries are exhausted held — budget cap reached; waits, never overspends cancelled — POST /api/v1/jobs/{job_id}/cancel
POST /api/v1/converse — keep your Converse code
Already calling the AWS Bedrock Converse API? POST /api/v1/converse accepts the same request body (modelId, messages, system, inferenceConfig) and returns the same response shape. Change the endpoint URL and the auth header; keep your marshalling code. The mechanics and the migration story are on the platform page.
curl -X POST "https://api.roundhouseworks.com/api/v1/converse?timeout=60" \
-H "Authorization: Bearer dsk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"modelId": "anthropic.claude-sonnet-5",
"messages": [
{"role": "user",
"content": [{"text": "Summarize this."}]}
],
"inferenceConfig": {"maxTokens": 512}
}'
{
"output": {
"message": {
"role": "assistant",
"content": [{"text": "The incident began at..."}]
}
},
"stopReason": "end_turn",
"usage": {
"inputTokens": 21,
"outputTokens": 118,
"totalTokens": 139
},
"metrics": {"latencyMs": 842},
"dispatch": {
"jobId": "0198f6a2-6b1e-7c3d-9a10-3f2e8c11d204",
"executor": "local_worker",
"tier": "tier-standard"
} // additive — Converse parsers ignore it
}
?timeout= becomes the deadline
Seconds to wait, default 60, max 300. Behind the facade the call is a real routed job with deadline_at = now + timeout — routing, escalation, metering and the audit trail all apply. Very short timeouts route straight to cloud, because local placement can't fit inside the safety margin.
modelId maps to a tier
Honestly stated: the model you name selects a capability tier, not a pinned model. The job may run on customer hardware advertising that tier — that is the product — and cloud escalation picks the cheapest model that satisfies it. Tier names like tier-standard are accepted directly.
Timeout returns 504 + job_id
If the timeout expires the response is a 504 whose error detail carries the job_id — the job keeps running, and the result stays fetchable from the jobs API. Sync callers get an upgrade path to async for free.
Shape compatibility, not wire-protocol emulation: point your HTTP client at the new URL — an unmodified boto3 client with SigV4 signing is out of scope. An OpenAI-compatible endpoint is planned next.
Everything the dashboard sees, you can see
The product's own dashboard has no private channel: the same org-scoped event feed is available to poll with a cursor or hold open as a Server-Sent Events stream. Every event, on every transport, is one envelope:
{"id", "type", "ts", "data"} — id is a time-ordered UUIDv7 that doubles as the cursor and the dedupe key; data carries ids and status fields only, never payload bodies. Three type families:
job.*— every lifecycle transition, fromjob.createdthroughjob.succeeded,job.escalated,job.dead_letteredand friends.worker.status— worker lifecycle (registered,online,draining,offline), with current load for fleet gauges.metering.recorded— one per finished attempt, local and cloud alike. Savings ride inside this event: each carries the computed cost, the all-cloud baseline, and the difference, as decimal strings.
REST cursor feeds
- GET /api/v1/events?cursor=&types= Merged feed, poll-style
- GET /api/v1/metering/events Pre-filtered to metering, for billing exports
Pass the response's next_cursor back as cursor; the cursor is exclusive, so a polling loop yields each event exactly once. types takes a comma-separated list; limit defaults to 100, max 500.
# the envelope, flattened onto the data: line; # the id rides the SSE id: line — that is what # makes Last-Event-ID resume work id: 0198f6a1-355e-7f02-8b61-77e4d90312af event: metering.recorded data: {"type": "metering.recorded", "ts": "2026-08-23T17:41:35.512276+00:00", "job_id": "0198f69f-b7e4-7c55-a1d0-42e9f1c88b21", "provider": "local", "model_id": "local-llm", "tokens_in": 100, "tokens_out": 200, "computed_cost": "0.000000", "cloud_baseline_cost": "0.014400", "savings": "0.014400"}
SSE: GET /api/v1/feed
The same envelopes, pushed. Connect with ?cursor=<id> or a Last-Event-ID header and every newer event is replayed before the stream tails live. Mechanics you can rely on:
- Heartbeats — an SSE comment every 15 seconds of silence keeps idle connections alive through load balancers;
EventSourcenever sees them. - Named events — the server sets the SSE
event:field, so useaddEventListener('job.succeeded', …); the defaultonmessagenever fires. - Streams end on purpose after 1 hour —
EventSourcereconnects and resumes viaLast-Event-IDautomatically; raw clients reconnect themselves. - 5 concurrent streams per org. The sixth connection gets a
429(too_many_streams). Don't retry-loop it — fall back to polling/api/v1/events, which serves the identical envelopes. The dashboard itself does exactly this. - Dedupe by id — reconnects can re-deliver events at the resume boundary; keep the last id you processed and drop anything at or below it.
const es = new EventSource('/api/v1/feed');
let lastId = null;
function handle(e) {
// dedupe by id: UUIDv7 strings sort in time order
if (lastId !== null && e.lastEventId <= lastId) return;
lastId = e.lastEventId;
const data = JSON.parse(e.data); // flattened envelope
console.log(data.type, data.ts, data);
}
['job.succeeded', 'job.failed', 'worker.status',
'metering.recorded'].forEach(function (t) {
es.addEventListener(t, handle);
});
es.onerror = function () {
// after repeated errors: close, poll /api/v1/events
};
The pull-based worker protocol
Machines that execute jobs speak a small pull-based protocol with the worker scope: POST /api/v1/workers/register, a heartbeat that doubles as the config and cancellation channel, a long-poll claim, and attempt completion/failure reports. Everything is outbound HTTPS from the worker's side — the server never connects in, so it runs behind NAT and firewalls with zero inbound ports. You won't hand-roll these calls: the full protocol ships with the worker client (a pip-installed agent that fronts any OpenAI-compatible local server), and its docs come with it. If you're here because your hardware could be earning, start at providing capacity.
One error envelope, everywhere
Every error, from every endpoint, is the same shape — a code you can switch on, a human-readable message, and an optional structured detail:
| HTTP | Code | Meaning |
|---|---|---|
| 400 | validation_error | The request body didn't validate; detail lists the fields. |
| 404 | not_found | No such resource in your org. |
| 409 | conflict | The operation is invalid in the resource's current state. |
| 429 | backpressure | Slow down; the Retry-After header says by how much. |
| 504 | timeout | A synchronous call ran out of time; detail.job_id lets you fetch the result later. |
{
"error": {
"code": "timeout",
"message": "The job did not finish within the
timeout. It is still running; fetch the
result from the jobs API.",
"detail": {
"job_id": "0198f6a2-6b1e-7c3d-9a10-3f2e8c11d204"
}
}
}
Ready to build against it? API keys come with private-beta access. Join the waitlist — organizations are invited as capacity opens up.