Ultrac docs.
Use the API directly from this site's /v1, or run the local agent harness from a repository.
01 / Quickstart
Send a chat completion.
The API is OpenAI-compatible and mounted on this site at /v1. There is no separate API hostname: whatever origin you are reading this on is the origin you call. Any OpenAI SDK works unchanged once its base URL points here.
Base URL
https://ultrac.io/v1
Auth
Bearer ultrac_sk_...
Model
cosmic-1
curl https://ultrac.io/v1/chat/completions \
-H "Authorization: Bearer $ULTRAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "cosmic-1",
"messages": [{"role": "user", "content": "Analyze this incident."}]
}'02 / Authentication
Keys, and how to send one.
Create keys on the API keys page once your account is approved. A key looks like ultrac_sk_ followed by 43 characters; it is shown once, stored only as a hash, and can be revoked at any time - revoked keys answer 401 on the very next request. Up to ten active keys per account, so each app or machine can have its own.
| Header | Form | Notes |
|---|---|---|
| Authorization | Bearer ultrac_sk_... | What every OpenAI SDK sends. Preferred. |
| x-api-key | ultrac_sk_... | Accepted for clients that cannot set an Authorization header. |
Keys are not for browsers: the API sets no CORS headers and reads no cookies. Send them from servers, scripts, the CLI and the desktop app.
03 / CLI agent
Sign the CLI in, then run the harness from a repo.
ultrac login asks for a key, checks it against /v1/models, and keeps it in ~/.ultrac/auth.json. After that, ultrac keeps agent work local by default: preflight the worktree, capture the working context, plan the checks, save the report, then pick a mode that matches the task.
# Sign the CLI in with the key you created on this page.
ultrac login --base-url https://ultrac.io/v1
# Paste the key when asked. It is checked against https://ultrac.io/v1/models
# and kept in ~/.ultrac/auth.json - never in the repository you run it from.security
Scope, evidence, and authorization first.
coding
Small patches, explicit checks, clean handoff.
review
Findings before summary.
04 / Request
POST /v1/chat/completions.
Send ordered messages to POST /v1/chat/completions. The reply is OpenAI's chat.completion object, or with stream: true the same server-sent chat.completion.chunk events OpenAI sends, ending in data: [DONE].
| Field | Required | Behaviour |
|---|---|---|
| model | No | Only cosmic-1 is served. Omit it or name it; anything else is 404 model_not_found. |
| messages | Yes | Ordered {role, content} objects; role is system, user, assistant or tool and content is a string or an array of text parts. An assistant message may carry tool_calls (content may be null then); a tool message carries tool_call_id and answers one call in the assistant message before it, once. Blank entries are skipped; a malformed entry, a non-text part or a tool message that answers no call (or a call already answered) is 400 naming the entry; a single message longer than half the context window is 400 context_length_exceeded rather than cut. History beyond the window is trimmed from the oldest turn, keeping the system prompt and never parting a tool call from its results; when the newest turn cannot fit alongside the system prompt and the tools even so, that is 400 context_length_exceeded too, never a prompt the model cannot hold. |
| stream | No | true for server-sent event chunks, passed through from the model host unchanged - including delta.reasoning_content, which carries the model's thinking when it does any, and the final usage chunk. |
| max_tokens | No | Capped at the reply budget the context window reserves (max_completion_tokens is accepted as the same thing). A smaller value is honoured. |
| temperature, top_p, stop | No | Passed through when well-formed. stop is a string or up to four strings. |
| tools | No | OpenAI function tools, forwarded to the model as checked: up to 128 of {type: "function", function: {name, description, parameters, strict}}, 64 KiB serialised, parameters at most 64 levels deep; keys outside that shape are dropped. The tools count against the context window like the history does. A call comes back as tool_calls on the reply with finish_reason tool_calls, or as tool_calls deltas when streaming. A malformed tool is 400 invalid_tools naming the entry. |
| tool_choice, parallel_tool_calls | No | With tools: none, auto, required or {type: "function", function: {name}} naming one of them, and a boolean. Forwarded as checked; either without tools is 400 invalid_tools. |
| chat_template_kwargs | No | Exactly {"enable_thinking": <boolean>} and nothing else. cosmic-1 thinks before it answers: the host splits that pass onto reasoning_content beside content, so it never arrives as the answer, but it is generated and spent from the reply budget. Send false to skip it and keep those tokens for the answer; true keeps it. Any other key or a non-boolean is 400 invalid_chat_template_kwargs naming the field. |
| anything else | - | Ignored, never forwarded. |
curl -N https://ultrac.io/v1/chat/completions \
-H "Authorization: Bearer $ULTRAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "cosmic-1",
"stream": true,
"messages": [{"role": "user", "content": "Analyze this incident."}]
}'One request, one reply: a completion has to finish before the connection does, streaming or not. Work that runs for minutes - an agent loop, a long review - belongs in a task instead, which you can disconnect from and rejoin: see Long-running tasks.
05 / Models
GET /v1/models.
The cheapest authenticated call: lists the one served model in OpenAI's list shape. The CLI uses it to check a key on login, and it answers even when the model host is down, because "is this key good" is a different question from "is the model up".
curl https://ultrac.io/v1/models \
-H "Authorization: Bearer $ULTRAC_API_KEY"06 / Long-running tasks
Queue it, then follow the log.
A completion has to finish inside one request, and a task that takes minutes cannot: functions are capped, laptops sleep, tabs close, and each time the model's work goes with the connection. So a task is a record and a log rather than a request. POST /v1/runs answers 202 with the run before any model work starts, a worker beside the model does the work and appends events as it goes, and you read those events as server-sent events - live, later, or from wherever you dropped. Disconnecting costs nothing: the task keeps running and every event waits in the log, which is kept for 30 days. The dashboard's Tasks page is these same five endpoints behind a cookie.
| Endpoint | Sends | Answers |
|---|---|---|
| POST /v1/runs | { prompt } or { messages }, optional chatId | 202 with the run and a location header. Counted against the per-minute and per-day allowances like a completion. |
| GET /v1/runs | ?limit=&cursor= | { object: "list", data, next_cursor }, newest first. Rows carry no input or output. |
| GET /v1/runs/{id} | - | The run, with its input and, once it is over, its output. |
| GET /v1/runs/{id}/events | ?since= or Last-Event-ID | text/event-stream: the log from where you left off, then live. |
| POST /v1/runs/{id}/cancel | - | 200 cancelled, 202 requested, 409 already finished. |
Every one takes the same bearer key as the rest of /v1, and every one is scoped to the key's account: another account's id answers 404, the same as an id that never existed, so ids cannot be probed.
The body is { "prompt": "..." } - one user turn - or { "messages": [...] } in the shape chat completions takes; send both and the prompt becomes the newest turn. An optional chatId your account owns attaches the finished turn to that dashboard chat. Messages are trimmed to the model window at creation and stored trimmed, so the input you get back is exactly what the model will be given. There is no stream field and no tools field: a task always streams into its log, and the worker sends no tools.
A run on the wire is { id, object: "run", status, source, model, chatId, title, createdAt, startedAt, finishedAt, attempt, lastSeq, cancelRequested, error, input, output }. title is the first line of the newest user message, and lastSeq is the id of the newest event, which is where a stream resumes from.
| Status | Meaning |
|---|---|
| queued | Written and waiting for a worker. Nothing has run yet, so cancelling now stops it outright. |
| running | A worker holds it under a 60-second lease and is streaming the model. attempt says which try this is: a worker that dies leaves the lease to lapse and the task is reclaimed and started again, up to three attempts. |
| succeeded | Finished. output is the final text, the same text the done event carried. Terminal. |
| failed | The model host refused or failed, or the task was given up after three attempts; error says which. Terminal. |
| cancelled | You asked it to stop. output is whatever had streamed before it did. Terminal. |
The event stream is plain server-sent events. The first line is retry: 1000, then one block per stored event in seq order: id: <seq>, event: <type>, data: <json>, blank line.
| Event | Data | What to do with it |
|---|---|---|
| status | { status, attempt?, restarted?, text?, synthetic? } | The run changed state. running means a start or a restart: reset whatever text you have accumulated. cancelled carries the partial text. A terminal status ends the stream. |
| delta | { text } | A piece of assistant text, already coalesced server-side (flushed at 150 ms or 400 characters, whichever comes first). Append it. |
| tool_call | { id, name, arguments } | A tool call the model made. Tasks are sent no tools, so this only appears if the model emits one on its own. |
| tool_result | reserved | Not written today; ignore it rather than treating it as an error. |
| done | { text, finish_reason, usage, tool_calls } | The final text, and it is authoritative: replace what you accumulated with it. Terminal. |
| error | { code, message, text? } | The task failed; text is what had streamed by then. Terminal. |
Each id is that event's seq, and seq is ordered per run, so rejoining is exact: reconnect with Last-Event-ID: <the last id you saw> and you get what you missed and nothing twice. A browser EventSource sends that header by itself; every other client sets it, or passes ?since=<seq>. The header wins when both are there, because it names what the client actually received. ?since=0 replays the whole log, which is what to trust when rebuilding the text from scratch.
A comment line (: keep-alive) goes out after 15 seconds of silence so proxies hold the connection open. The connection itself is capped at 55 seconds, under the 60-second function limit: the stream ends cleanly on purpose rather than being killed mid-write, so treat a clean end as normal and reconnect - only a terminal event means the task is over. If a task ended without a terminal event ever reaching its log, the stream notices from the run's own status and closes with a synthetic status event (synthetic: true) whose id repeats the last real seq, so no client waits forever.
An account may have 3 tasks queued or running at once; a task holds a worker for as long as the model takes, and the workers sit beside one GPU. The next create answers 429 too_many_active_runs, whose message names how many are active and what the limit is - wait for one to finish, or cancel one. That is separate from the rate limits above: creating a task also counts against the plan's per-minute and per-day allowances like a completion, and going over those is the usual 429 rate_limit_exceeded with retry-after. Reading, streaming and cancelling count per minute only.
Cancelling is immediate only while a task is queued: there is no worker to ask, so it is cancelled on the spot and the answer is 200 with outcome: "cancelled". On a running task the flag is set and the answer is 202 with outcome: "requested"; the worker reads the flag every two seconds, stops the model, writes a status: cancelled event carrying the partial text, and finishes the task with that text as its output. So 202 means asked, and the stream is what tells you it stopped. A task that had already finished answers 409 run_already_finished; cancelling one that is already cancelling is harmless.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_body, invalid_prompt, no_messages | Fix the body; param names the field. Send prompt as a string, or messages as a non-empty array. |
| 404 | chat_not_found | The chatId is not one this account owns. |
| 404 | run_not_found | No task with that id belongs to this account. The same answer as an id that never existed. |
| 409 | run_already_finished | Cancel arrived after the task was over; the message names the status it ended in. |
| 429 | too_many_active_runs | Already 3 queued or running. Not a rate limit: waiting a minute is not enough, one has to end. |
| 503 | not_configured, service_unavailable | Tasks are not set up on this deployment, or the store could not be reached. Your key is not the problem. |
# 1. Queue the task. The answer is 202 with the run, before any model work starts.
curl -s https://ultrac.io/v1/runs \
-H "Authorization: Bearer $ULTRAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Audit this incident timeline and list what is missing."}'
# {"id":"6f1a...","object":"run","status":"queued","attempt":0,"lastSeq":0,...}
RUN=6f1a... # the id from step 1
# 2. Follow it. -N keeps curl from buffering, so events print as they are written.
curl -N https://ultrac.io/v1/runs/$RUN/events \
-H "Authorization: Bearer $ULTRAC_API_KEY"
# retry: 1000
#
# id: 1
# event: status
# data: {"status":"running","attempt":1}
#
# id: 2
# event: delta
# data: {"text":"The timeline is missing "}
#
# : keep-alive
#
# id: 7
# event: done
# data: {"text":"...","finish_reason":"stop","usage":{...}}
# 3. The connection is capped at 55 s. If the task is still going when it ends,
# reopen with the last id you saw: you get what you missed and nothing twice.
curl -N https://ultrac.io/v1/runs/$RUN/events \
-H "Authorization: Bearer $ULTRAC_API_KEY" \
-H "Last-Event-ID: 7"
# Polling works too, if a stream is awkward: the run carries its status, and
# once it is over, the whole output.
curl -s https://ultrac.io/v1/runs/$RUN \
-H "Authorization: Bearer $ULTRAC_API_KEY"
# Stop it early: 200 cancelled while still queued, 202 requested once a worker
# has it, 409 if it had already finished.
curl -s -X POST https://ultrac.io/v1/runs/$RUN/cancel \
-H "Authorization: Bearer $ULTRAC_API_KEY"07 / Limits
Stated per plan, enforced per key and per account.
Every account has a per-minute and a per-day allowance from the plan confirmed on it; until a plan is confirmed, Free applies whichever plan was chosen. Every key has its own per-minute bucket at the account's figure, so a 429 names the key that is hot and ten keys cannot multiply the plan. Over the limit answers 429 with retry-after; every reply carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset for the window you are closest to. Nothing is billed.
| Plan | API / minute | API / day | Dashboard / minute |
|---|---|---|---|
| Free | 10 | 200 | 20 |
| Pro | 60 | 2,000 | 40 |
| Teamat 3 seats; daily grows per seat | 300 | 9,000 | 60 |
| Ultra | 1,200 | 40,000 | 120 |
08 / Errors
OpenAI's shape, so your SDK already handles them.
Every error body is { error: { message, type, param, code } }. SDKs map the status to the exception they already have; code is the stable thing to branch on.
{
"error": {
"message": "Incorrect API key provided. Check the key, or create a new one at /keys.",
"type": "invalid_request_error",
"param": null,
"code": "invalid_api_key"
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_body, invalid_messages, unsupported_content, invalid_tools, invalid_chat_template_kwargs, context_length_exceeded | Fix the request body; param names the field. |
| 400, 413, 422 | upstream_rejected | The model host read the request and refused it; the message carries its reason. Not retried by SDKs, so fix the request. |
| 401 | missing_api_key, invalid_api_key, api_key_revoked | No key, an unknown key, or one that has been turned off. Create one at /keys. |
| 403 | account_not_approved | The key is real but its account is not approved for API access yet. |
| 404 | model_not_found | Name cosmic-1 or leave model out. |
| 429 | rate_limit_exceeded | Back off for retry-after seconds. The x-ratelimit headers say which window you hit: your key, your account, your day, or your address. |
| 502 | upstream_unreachable, upstream_error | The model host could not be reached or failed on its side; the message carries its reason. Retry with backoff. |
| 503 | not_configured, service_unavailable, account_unavailable | Our side: the host key is unset, or keys, limits or accounts could not be looked up. Your key is not the problem; retry shortly. |