Developers

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.

Overview

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.

Authentication

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:

ScopeGrants
jobs:readList jobs, fetch results, read event feeds
jobs:writeSubmit and cancel jobs, call the Converse endpoint
workerRegister workers, claim and complete work
adminAdminister 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).

every request
# the only header Roundhouse needs
curl "https://api.roundhouseworks.com/api/v1/jobs" \
  -H "Authorization: Bearer dsk_your_api_key"
Jobs API

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:

FieldMeaning
payloadYour job body, any non-empty JSON. Required.
required_capabilityCapability tier the job needs: tier-basic, tier-standard, or tier-frontier. Required.
priority1–10 (10 highest). Default 5. Breaks ties in the queue.
deadline_atISO 8601. Drives placement: local when your hardware can finish in time, cloud escalation when it can't. Optional.
idempotency_keyAny 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.
tagsList of strings for filtering and reporting.
estimated_output_tokensOptional 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.

POST /api/v1/jobs
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 in dead_letter for inspection.
  • held means 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.
job lifecycle
          ┌─▶ 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
Bedrock migration

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.

request — your existing Converse body
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}
      }'
response — Converse shape, additive metadata
{
  "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.

Real-time event feeds

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, from job.created through job.succeeded, job.escalated, job.dead_lettered and 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.

GET /api/v1/feed — one SSE frame
# 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; EventSource never sees them.
  • Named events — the server sets the SSE event: field, so use addEventListener('job.succeeded', …); the default onmessage never fires.
  • Streams end on purpose after 1 hour — EventSource reconnects and resumes via Last-Event-ID automatically; 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.
EventSource
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
};
Workers & capacity

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.

Errors

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:

HTTPCodeMeaning
400validation_errorThe request body didn't validate; detail lists the fields.
404not_foundNo such resource in your org.
409conflictThe operation is invalid in the resource's current state.
429backpressureSlow down; the Retry-After header says by how much.
504timeoutA synchronous call ran out of time; detail.job_id lets you fetch the result later.
the error envelope
{
  "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.