All documentation / API reference
Octodus API
Last updated 2026-09-30
Embed an Octodus AI agent inside your own product: create isolated spaces (one per customer or workflow), hand each one your app’s own API, tools and a durable manifest, send prompts (with file attachments), and read replies and produced files, billed per end user.
Octodus is a general agent-space substrate. A space is one isolated agent:
Space = Agent core + Channel + Toolset + Manifest + Routing policy
Telegram (@octodus_bot) is one channel; this HTTP API is another. Your backend creates spaces, configures them, sends turns (prompts, optionally with attached files), and reads replies and any files the agent produced. You pay Octodus in aggregate and re-bill your own users using the per-end-user breakdown.
Base URL and auth
| Item | Value |
|---|---|
| Base URL | https://dash.octodus.com/v1 |
| Auth header | Authorization: Bearer octo_live_… on every request |
| Machine-readable schema | /api/openapi.json (Swagger UI at /api/docs; it also lists dashboard-internal routes, this page is the public contract) |
| Content type | application/json (file upload is multipart/form-data; file download streams bytes) |
| Errors | { "error": { code, message, details, trace_id } } plus an X-Trace-Id response header (see Error codes) |
A missing or invalid key returns 401 INVALID_KEY. The plaintext key is shown once at creation, so store it in your secret manager.
Origin header is refused with 403 {"detail":"Cross-site request blocked"}. Keep the key on your backend and proxy for your front end.Get a key and scope it
Create keys in the dashboard under Integrations → API Keys → Create key, then choose Space access:
| Choice | Scope | What the key can control |
|---|---|---|
| Only spaces this key creates (default, recommended) | own | Only the spaces it provisions with POST /v1/spaces. It can never touch your existing (Telegram or other) spaces. |
| One specific space | space | Exactly one space slug you name, whatever its origin. Any other slug answers 404 SPACE_NOT_FOUND. |
| All my spaces | account | Every API-created space your account owns. |
- Daily cap per key. Each key has a daily cap in energy, default 50 energy per day, adjustable per key in the dashboard up to 10,000 energy. It resets at 00:00 UTC. See Billing in energy.
- A turn belongs to the key that created it.
GET /v1/turns/{turn_id}, cancel and/deliveriesanswer404 TURN_NOT_FOUNDfor any other key, even on the same account. Space-level reads (GET /v1/spaces/{slug}/turns,GET /v1/turns/{turn_id}/files) work for any key that can reach the space. - A space’s own key. Each space also has a key bound to it alone; rotate or revoke it on the space’s status page. It behaves like One specific space.
The golden path
1. POST /v1/spaces {title, app_api} → { space_id } # create a space and hand it your app’s API; store the slug
2. PUT /v1/spaces/{slug}/manifest # (optional) how the agent behaves (durable)
PUT /v1/spaces/{slug}/secrets # (optional) more env vars for its tools (encrypted, write-only)
PUT /v1/spaces/{slug}/tools # (optional) allowlist built-ins, register other MCP servers
3. POST /v1/spaces/{slug}/files # (optional) upload an attachment → { file_id }
POST /v1/turns {space, text:"Handle request 42", end_user_ref} → 202 { turn_id, state:"queued" }
# the space claims request 42 and writes its results and statuses into your app, through your API
4. webhook turn.completed | turn.failed # a “turn finished” ping (see Turn finished), or
GET /v1/turns/{turn_id} # state | error | energy_used; result is text for a person
5. GET /v1/files/{file_id} # download a file the agent produced (Bearer + scoped)
GET /v1/usage?group_by=end_user # per-end-user energy, to re-bill your users
6. GET /v1/tasks/{task_id} # (when the turn lists tasks) follow longer work
Steps 1 and 2 configure once; steps 3 to 5 repeat per message. Your app shows progress from its own database: the statuses the space set. end_user_ref is your own opaque customer id (like OpenAI’s user parameter). It drives per-user billing and caps; it is not an Octodus account and needs no registration.
Build on your app’s own API
A turn does not carry data back to you. It is a message (“Handle request 42”), and its result is free text for a person: show it or log it, never parse JSON or scrape data out of it. The work happens inside your app, through your app’s own API:
- Build the human workflow first. Every action a person takes in your UI is an operation of your API, with your normal auth: create or claim a request, fetch its file, write the result lines, set the supplier or object, set the status, send to review. Your audit log shows who did it: a person, or “Octodus acting as delegate of Maria”. A person can do the same job with the same requests without Octodus. There is no Octodus-specific result endpoint, result format or
submit_resulttool. - Delegate that work to a space. Your app issues a credential for the user’s Octodus delegate and hands the space its API when it creates the space, in one call (
app_apibelow). The space reads the inputs and writes results and statuses itself, re-reads what it wrote to check it, and fixes and retries a write your app refused. Refuse a bad write with a4xxand a reason, exactly as you would for a person. - Send only the trigger.
POST /v1/turns {"space": "...", "text": "Handle request 42"}with anIdempotency-Key. Your UI shows progress from your own database. A webhook only tells you that a turn finished: refresh a screen, or notice a failed turn and send the request again.
POST /v1/spaces
{
"title": "Orders for Maria",
"app_api": {
"name": "orders", // letters, digits, _ and -, up to 48; default "app"; also the MCP server name
"base_url": "https://orders.acme.com/api", // REST base (https, public host); base_url and/or mcp_url
"mcp_url": "https://orders.acme.com/mcp", // optional: registered as the space’s MCP server
"docs_url": "https://orders.acme.com/api/openapi.json", // optional
"credentials": {"ORDERS_API_TOKEN": "<delegate token>"}, // up to 8; encrypted, never returned
"headers": {"Authorization": "Bearer ${secret:ORDERS_API_TOKEN}"}, // optional: the default with one credential
"acts_for": "[email protected]", // optional: whose delegate
"instructions": "Requests: claim, fetch the file, add lines, set status, send to review" // optional, up to 4000
}
}
# → 201 {"space_id": "api-...", "origin": "api", "app_api": {"name": "orders", "base_url", "mcp_url", "docs_url",
# "headers", "secrets": ["ORDERS_API_TOKEN"], "acts_for", "instructions", "added_by", "updated_at", "mcp_registered": true}}
- All or nothing. A bad
app_apicreates no space:400 INVALID_REQUESTfor anhttpURL, a private host or another space’s Lizard app, a literal credential in a header, or a header that references a secret you did not send;422 RESERVED_SECRET_NAMEforOOIH_*or engine names such asOPENAI_API_KEY. A Lizard URL may be the space’s own deployment or its main space’s. - What the space sees: the tools of the MCP server, the credentials as environment variables of its tools (never in the chat), and an “App API: orders” block in its manifest.
PUT /v1/spaces/{slug}/manifestkeeps that block and replaces only your own text. - Audited. Every change is written to the space’s log with your key’s label and prefix: secret names only, never values.
| Method and path | Purpose |
|---|---|
PUT /v1/spaces/{slug}/app-api/{name} | Register or replace (the app_api body, without name); also how you connect an existing space. Credentials you send replace the set it holds (a name you no longer send is deleted); send none to keep the stored ones. → {slug, app_api}. |
PATCH /v1/spaces/{slug}/app-api/{name} | Change only the fields sent. {"credentials": {"ORDERS_API_TOKEN": "<new>"}} rotates the token. 404 APP_API_NOT_FOUND if it is not registered. |
GET /v1/spaces/{slug}/app-api | {slug, app_apis:[…]} in the shape above, never a credential value. |
DELETE /v1/spaces/{slug}/app-api/{name} | → {ok, slug, deleted, secrets_deleted:[names]}. Removes its MCP server, its manifest block and those secrets. |
A name already used by an MCP server you registered through /tools is 409 CONFLICT.
Quickstart in code
Each example does the same four things: create a space, send a turn with an Idempotency-Key and an end_user_ref, poll until the turn reaches a terminal state, and print the result. Put your key in the OCTO_API_KEY environment variable.
curl (bash + jq)
BASE=https://dash.octodus.com/v1
AUTH="Authorization: Bearer $OCTO_API_KEY"
SPACE=$(curl -sf -X POST "$BASE/spaces" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"title":"Quickstart"}' | jq -r .space_id)
TURN=$(curl -sf -X POST "$BASE/turns" -H "$AUTH" -H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"space":"'"$SPACE"'","end_user_ref":"customer-42","text":"Write a haiku about queues."}' \
| jq -r .turn_id)
while :; do
R=$(curl -sf "$BASE/turns/$TURN" -H "$AUTH")
case $(echo "$R" | jq -r .state) in
done) echo "$R" | jq -r .result; break ;;
failed|cancelled) echo "$R" | jq .error; break ;;
esac
sleep 2
done
Python (requests)
import os, time, uuid
import requests
BASE = "https://dash.octodus.com/v1"
http = requests.Session()
http.headers["Authorization"] = f"Bearer {os.environ['OCTO_API_KEY']}"
def call(method, path, **kwargs):
# Every call returns at once (a turn runs in the background), so 30 s per
# request is plenty; the wait for the turn itself is the deadline below.
r = http.request(method, BASE + path, timeout=30, **kwargs)
if r.status_code >= 400:
raise RuntimeError(f"{r.status_code}: {r.text}")
return r.json()
space = call("POST", "/spaces", json={"title": "Quickstart"})["space_id"]
turn = call("POST", "/turns",
json={"space": space, "end_user_ref": "customer-42",
"text": "Write a haiku about queues."},
headers={"Idempotency-Key": str(uuid.uuid4())})
delay, deadline = 1.0, time.time() + 300 # give a turn at least 180 s
while True:
t = call("GET", f"/turns/{turn['turn_id']}")
if t["state"] in ("done", "failed", "cancelled") or time.time() > deadline:
break
time.sleep(delay)
delay = min(delay * 1.5, 10)
if t["state"] == "done":
print(t["result"])
print("energy_used:", t["energy_used"])
else:
print(t["state"], t["error"])
TypeScript (fetch, Node 18+)
import { randomUUID } from "node:crypto";
const BASE = "https://dash.octodus.com/v1";
const KEY = process.env.OCTO_API_KEY;
type TurnState = "queued" | "running" | "done" | "failed" | "cancelled";
interface Turn {
turn_id: string;
state: TurnState;
result: string | null;
error: { code: string; message: string } | null;
energy_used: number | null;
files?: { file_id: string; name: string; url: string }[];
}
async function call<T>(method: string, path: string, body?: unknown,
headers: Record<string, string> = {}): Promise<T> {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
async function main() {
const { space_id } = await call<{ space_id: string }>("POST", "/spaces", { title: "Quickstart" });
const { turn_id } = await call<{ turn_id: string }>(
"POST", "/turns",
{ space: space_id, end_user_ref: "customer-42", text: "Write a haiku about queues." },
{ "Idempotency-Key": randomUUID() },
);
let delay = 1000;
const deadline = Date.now() + 300_000; // give a turn at least 180 s
let turn: Turn;
for (;;) {
turn = await call<Turn>("GET", `/turns/${turn_id}`);
if (turn.state === "done" || turn.state === "failed" || turn.state === "cancelled") break;
if (Date.now() > deadline) break;
await new Promise((r) => setTimeout(r, delay));
delay = Math.min(delay * 1.5, 10_000);
}
if (turn.state === "done") console.log(turn.result, "\nenergy_used:", turn.energy_used);
else console.error(turn.state, turn.error);
}
main().catch((e) => { console.error(e); process.exit(1); });
Reuse the space: create it once per customer or workflow and keep sending turns to the same space_id. In production your app does not wait on a turn: the space does the work through your app’s API (see Build on your app’s API), and a webhook tells you that a turn finished.
Knowing a turn finished: webhook or poll
Every turn runs in the background: POST /v1/turns answers 202 at once with a turn_id, and the turn finishes seconds to hours later. Neither way below carries your data: the space already wrote it into your app through your API (see Build on your app’s API). They tell you that a turn finished or failed, with its error, energy_used and the result text for a person: refresh a screen, log the text, or send a failed request again.
| Webhook (recommended) | Polling | |
|---|---|---|
| How | We POST a signed “turn finished” event to your HTTPS endpoint | You call GET /v1/turns/{turn_id} until the state is final |
| Use it for | Production backends | Scripts, prototypes, a backend that cannot take inbound calls |
| Payload | The same in both: the webhook’s data is exactly what the poll returns | |
Polling is what the quickstart does and needs no setup. For production, do this:
- Expose one HTTPS endpoint on your backend, reachable from the internet (for example
https://api.example.com/octo/webhook). To try it from your laptop, put a public HTTPS tunnel in front of it. - Register it once per API key and store the
secretfrom the response in your secret manager. It is shown only once;PATCH /v1/webhooks/{webhook_id} {"rotate_secret": true}issues a new one.BASE=https://dash.octodus.com/v1 AUTH="Authorization: Bearer $OCTO_API_KEY" curl -s -X POST "$BASE/webhooks" -H "$AUTH" -H 'Content-Type: application/json' \ -d '{"url":"https://api.example.com/octo/webhook","events":["turn.completed","turn.failed"]}' # -> {"id":12,"url":"https://api.example.com/octo/webhook","events":["turn.completed","turn.failed"], # "space":null,"active":true,"disabled_reason":null,"consecutive_failures":0,..., # "secret":"whsec_..."} <- shown ONCE: put it in your secret manager - Send turns as usual, with an
Idempotency-Key, and store the returnedturn_idnext to your own record. If you need your own id back, put it inmetadata; it is echoed in every event. - In your endpoint: verify
X-Octo-Signatureover the raw request body, answer2xxwithin 10 s, then process. Skip an event whoseidyou have already processed: a retry sends the same event again.turn.completedcarriesresult(text for a person),filesandenergy_used;turn.failedcarrieserror: send the request again (see Timeouts and retries). - Keep a safety net. For a turn with no final event after 10 minutes, poll
GET /v1/turns/{turn_id}once; that covers a delivery your endpoint missed while it was down. - Watch its health.
GET /v1/webhooksshowsactive,disabled_reasonandconsecutive_failures. We switch off a webhook that keeps failing (see reference); fix the endpoint, thenPATCH {"active": true}, and replay what it missed withredeliver.
Receiver in Python (FastAPI)
import hashlib, hmac, json, os, time
from fastapi import BackgroundTasks, FastAPI, Request, Response
SECRET = os.environ["OCTO_WEBHOOK_SECRET"] # the whsec_... value from registration
app = FastAPI()
def verify(raw: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance: # refuse replays older than 5 min
return False
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/octo/webhook")
async def octo_webhook(request: Request, background: BackgroundTasks):
raw = await request.body() # the RAW bytes, before any JSON parsing
if not verify(raw, request.headers.get("X-Octo-Signature", "")):
return Response(status_code=400)
event = json.loads(raw)
background.add_task(handle, event) # answer 2xx first, work afterwards
return Response(status_code=200)
def handle(event: dict) -> None:
if already_processed(event["id"]): # your store: retries resend the same id
return
data = event["data"]
if event["type"] == "turn.completed": # a ping: the work is already in your database
log_turn(data["turn_id"], data["result"], data["energy_used"]) # result: text for a person
elif event["type"] == "turn.failed":
resend_later(data["turn_id"], data["error"]) # "Handle request 42" again, with backoff
mark_processed(event["id"])
Receiver in Node (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.OCTO_WEBHOOK_SECRET; // the whsec_... value from registration
const app = express();
function verify(raw, header, tolerance = 300) {
const parts = Object.fromEntries((header || "").split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
}));
const ts = Number(parts.t);
if (!Number.isInteger(ts) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false; // refuse replays older than 5 min
const expected = createHmac("sha256", SECRET).update(`${parts.t}.`).update(raw).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}
// express.raw keeps the body as the exact bytes that were signed.
app.post("/octo/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-Octo-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer 2xx first, work afterwards
handle(event).catch(console.error);
});
async function handle(event) {
if (await alreadyProcessed(event.id)) return; // your store: retries resend the same id
const data = event.data;
// a ping: the work is already in your database; result is text for a person
if (event.type === "turn.completed") await logTurn(data.turn_id, data.result, data.energy_used);
else if (event.type === "turn.failed") await resendLater(data.turn_id, data.error); // send the request again
await markProcessed(event.id);
}
app.listen(3000);
Following tasks works the same way: add task.created, task.updated, task.completed and task.failed to events, and handle them in the same endpoint. Ignore event types you do not handle; new ones may be added.
Endpoints: spaces
| Method and path | Purpose |
|---|---|
POST /v1/spaces → 201 | Create an isolated API space. Body {title?, app_api?}. Returns { "space_id": "api-<12 hex>", "origin": "api" }, plus app_api when you sent one (see Build on your app’s API). A space created without engine/model follows the platform routing policy, like every space (see Routing policies). engine/model are still accepted but deprecated. |
GET /v1/spaces | List the spaces this key can reach: {spaces:[{slug, title, engine, model, origin, created_at, has_manifest, has_secret, has_tools}]}. An own key lists only the ones it created; a space-bound key lists its one space. |
PATCH /v1/spaces/{slug} | Deprecated. Change the space’s model with {model_pin} (an id from GET /v1/models) or {engine, model?}. Use PUT /v1/spaces/{slug}/policy instead. |
GET /v1/spaces/{slug}/turns?limit=50 | Recent turns for a space from any of your keys, newest first (max 200): {turns:[…]}. |
PUT /v1/spaces/{slug}/manifest | Set the durable manifest. Body { "manifest": "..." } → {ok, slug}. |
GET /v1/spaces/{slug}/manifest | Read it: {slug, manifest}. |
PUT /v1/spaces/{slug}/limits | The space's DAILY energy cap, set by the key that created the space. Body { "daily_energy": N } (null clears) → the state below. Resets at UTC midnight; a turn past it is refused 429 QUOTA_EXCEEDED with details.limit_reached: "space_daily_energy", resets_at and Retry-After. The key's own daily cap still applies; a cap the owner set is theirs (403 OWNER_LIMIT_SET). |
GET /v1/spaces/{slug}/limits | Read it: {daily_energy, set_by, spent_today_energy, remaining_energy, resets_at, key_daily_cap_energy, effective_daily_energy}. |
PUT /v1/spaces/{slug}/secret | Store the space’s single app credential (write-only, encrypted, never returned). Body { "secret": "..." }. For several named values use named secrets. |
PUT / POST /v1/spaces/{slug}/secrets | Upsert named secrets (environment variables your tools read). See Named secrets. |
GET /v1/spaces/{slug}/secrets | List secret names only: {secrets:[{name, system}]}. |
DELETE /v1/spaces/{slug}/secrets/{name} | Delete one named secret (404 SECRET_NOT_FOUND if absent). |
PUT /v1/spaces/{slug}/tools | Replace the toolset (see Tools and MCP). |
PATCH /v1/spaces/{slug}/tools | Merge-update the toolset: only the fields you send (allow, mcp_servers) change. Returns {ok, slug, allow, mcp_servers}. |
GET /v1/spaces/{slug}/tools | Read the toolset config: {slug, allow, mcp_servers}. |
PUT / PATCH / GET / DELETE /v1/spaces/{slug}/app-api… | Hand a space your app’s API, rotate its token, list or remove it (see Build on your app’s API). |
GET /v1/tools/catalog | The built-in capability keys you may allowlist: {tools:[{key, label, billable?, gated?, yandex?}]}. |
GET /v1/spaces/{slug}/policy | The space’s routing policy: per kind, the configured choice, the model it resolves to now, the source and the typed reasons. |
PUT /v1/spaces/{slug}/policy | Set per-kind overrides. Body { "orchestration": "<model id>" }. This is the space’s model setting. |
DELETE /v1/spaces/{slug}/policy/{kind} | Drop the space’s override for one kind, back to the fleet default. |
GET /v1/models | The model catalog (id, label, eligible kinds, tier) plus kinds, settable_kinds and the live fleet_default policy. |
GET /v1/spaces/{slug} and DELETE. Keep your own map from your record to the returned space_id. (external_ref in the create body is accepted but not stored.)Named secrets (environment variables)
Named secrets are encrypted at rest and exposed to the agent’s tools as environment variables; values are never returned. They use the same store as the dashboard’s secrets editor. Send one or a batch:
curl -s -X PUT $BASE/spaces/$SLUG/secrets -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"secrets":{"ACME_API_KEY":"live_...","ACME_REGION":"eu"}}'
# or a single one: -d '{"name":"ACME_API_KEY","value":"live_..."}'
# → {"ok":true,"stored":["ACME_API_KEY","ACME_REGION"],"skipped_system":[],"skipped_invalid":[]}
- Names must match
[A-Z_][A-Z0-9_]*and the value must be non-empty, otherwise the name is returned inskipped_invalid. Platform-managed names come back inskipped_system. - Refused outright (nothing stored): names starting with
OOIH_(422 RESERVED_SECRET_NAME); names the AI engines read for their own configuration, such asOPENAI_*,ANTHROPIC_*,GEMINI_*,OPENROUTER_*(422 RESERVED_SECRET_NAME, store it asAPP_OPENAI_API_KEYinstead); bot token names such asTELEGRAM_BOT_TOKEN(400 RESERVED_SECRET_NAME). - Each write or delete is noted in the space’s thread (names only, never values), so the agent knows a new credential exists.
Routing policies
The platform does not run one engine per space. It routes each kind of work by policy:
- Kinds:
orchestration(the agent’s own turn),coding-plan,coding-design,coding-exec,thinking(a fixed platform escalation), plusunlabelled(a task that named no kind, which follows the space default). - Default: every space, an API space included, routes by the fleet default table until an override is set. An API space created without
engine/modelfollows the platform routing policy. The table changes as models are added, so read it live:GET /v1/modelsreturns it asfleet_default, a map of kind to candidate model ids, first choice first (nullwhen the platform could not be asked right now). - Per-space override: a space may override
orchestrationwithPUT /v1/spaces/{slug}/policy(or the owner on the dashboard’s Policies tab). The override is recorded with its provenance (set viaapiordashboard); an API space keeps its own model only when one was set this way. When it takes effect is theswitchyou choose; see Now or after the next dream. The coding kinds and the fleet table are operator levers and are refused withOPERATOR_ONLY. - The space’s model is a policy, not a pin: a model set for the space (the
orchestrationoverride, the dashboard picker, a turn’smodel, the chat’s/model) is written as the space’s policy for the orchestrator and every kind of work it can run; a kind it cannot run keeps its own model and the response names it inkept. Nothing sits above the policies; see Naming a model on a turn.model_state.configuredandpolicy_sourcesay who set it and from where. - Reasons: a model that cannot run right now is skipped with a typed reason (
INELIGIBLE,OUT_OF_BUDGET,AT_CAPACITY,UNHEALTHY) and the next candidate runs. - Chosen at session start: the model is picked when a session (or sub-agent session) starts and kept while it resumes, so a change applies to the next session, not a running one.
Read the policy: GET /v1/spaces/{slug}/policy (model ids below are illustrative; use the ids from GET /v1/models)
{
"space_id": "api-1a2b3c4d5e6f",
"available": true,
"kinds": [
{"kind": "orchestration", "configured": "<model id>", "source": "fleet_default",
"resolved": {"model_id": "<model id>", "engine": "claude-code", "model": "<engine model>"},
"first_choice": true, "reasons": [], "decided_by": "the policy for this kind of work",
"because": null, "settable": true},
{"kind": "coding-plan", "configured": "<model id>", "source": "fleet_default",
"resolved": {"model_id": "<model id>", "engine": "claude-code", "model": "<engine model>"},
"first_choice": true, "reasons": [], "decided_by": "...", "because": null, "settable": false}
],
"pinned": null,
"settable": ["orchestration"],
"operator_only": {"coding-plan": "an operator (tools/space_engine_admin.py coding-roles)", "...": "..."},
"model_state": {
"available": true, "reason": null, "policy_source": "default",
"current": {"model_id": "<model id>", "engine": "claude-code", "model": "<engine model>", "source": "policy"},
"configured": {"model_id": null, "engine": "claude-code", "model": null, "set_via": null},
"pending": null, "forced": null, "override": null,
"session": {"id": "...", "resumable": true, "turns": 4, "started_at": "...", "age_sec": 3600},
"context": {"used_tokens": 80000, "threshold_tokens": 100000, "window_tokens": 200000,
"threshold_source": "default", "used_pct": 80.0},
"estimate": {"avg_tokens_per_turn": 20000, "turns_until_dream": 1, "basis": "session-growth"},
"last_dream_at": "2026-09-29T08:00:00+00:00",
"capacity": {"pool": "claude", "status": "ok", "free": 3, "usable": 5, "why": null,
"capped": false, "degraded": false}
}
}
source is fleet_default, space_override, pin (an engine pin above the policy, for example an automatic failover) or platform (thinking). When a kind is not on its first choice, first_choice is false and reasons lists the typed codes. available: false means the platform could not be asked right now; the space still routes by its policy.
Set and clear an override:
curl -s -X PUT $BASE/spaces/$SLUG/policy -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"orchestration":"<model id from GET /v1/models>"}'
# → {"space_id":"api-...","switch":"soft","applied":{"orchestration":{"kind":"orchestration",
# "model_id":"...","engine":"...","model":"...","switch":"pending"}},
# "note":"Pending: applied at the session's next dream; ...","model_state":{...}}
curl -s -X PUT $BASE/spaces/$SLUG/policy -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"orchestration":"<model id>","switch":"force"}'
curl -s -X PUT $BASE/spaces/$SLUG/policy -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"switch":"cancel"}'
curl -s -X DELETE $BASE/spaces/$SLUG/policy/orchestration -H "Authorization: Bearer $KEY"
# → {"space_id":"api-...","kind":"orchestration","cleared":true,"source":"fleet_default"}
The write is the same one the dashboard’s Policies tab makes. Refusals: 400 INVALID_KIND, 403 OPERATOR_ONLY (details.who names who sets it), 403 INELIGIBLE, 400 UNKNOWN_MODEL. A premium model the owner’s balance does not clear is stored but answered with warning.code: OUT_OF_BUDGET, and the orchestrator falls back down its policy until the owner tops up.
Now or after the next dream
"switch": "soft"(the default): the model waits for the session’s next dream (the roll that consolidates it into memory), so the running session and its prompt cache are kept. Setting again replaces the pending switch; it shows asmodel_state.pendingwithwaits_for: "next_dream". An API space that has not dreamed within 6 hours moves on its next turn (pending.adopt_by), when its cache has long expired."switch": "force": before the next turn the session is dreamed (its memory kept) and restarted, and that turn runs on the new model. A turn in flight finishes on the old one.model_state.forced.rolledsays whether the session has rolled for it.- A dream always runs on the model of the session it saves (it resumes that session), so a switch never re-routes the dream of the closing session, and there is no dream model to set.
"switch": "cancel": drops a pending switch; name no model with it.
model_state (on GET and in the PUT response) is what a switch is decided on: current (what runs), configured, pending, forced, policy_source (default | api | dashboard | admin), context (tokens used against the dream threshold, used_pct), estimate (average context growth per turn and turns_until_dream), last_dream_at, session (resumable, turns, age_sec), capacity (the model’s pool headroom, fleet cap, degraded) and override (an engine override in force). available: false means the platform could not be asked; those numbers are then null, never zero. The dashboard’s model picker offers the same two options with the same information.
engine / model. POST /v1/spaces {engine, model} and PATCH /v1/spaces/{slug} {model_pin | engine, model} still work for backward compatibility. They set the space’s orchestration override (the same as PUT .../policy {"orchestration": ...}) and the response carries a deprecation note. Omit them to use the policy.Endpoints: turns
| Method and path | Purpose |
|---|---|
POST /v1/turns → 202 | Send a prompt. Body {space, text, end_user_ref?, metadata?, file_ids?, voice_file_id?, webhook_url?, webhook_secret?, model?, engine?, stateless?}. file_ids attaches uploads (see Files); voice_file_id sends a recording instead of (or with) text (see Voice input); model/engine move the space to a model, which then stays (see below); stateless runs it with no history (see below). Header Idempotency-Key. Returns {turn_id, state:"queued", trace_id} plus a Location: /v1/turns/{turn_id} header. |
GET /v1/turns/{turn_id} | The turn’s state, error, energy_used and result text (see lifecycle): to check that it finished or failed, not to fetch data. The response also carries a files array of the turn’s attached and produced files. |
POST /v1/turns/{turn_id}/cancel | Cancel a non-terminal turn; returns the turn with state:"cancelled" (else 409 ALREADY_TERMINAL). |
GET /v1/turns/{turn_id}/files | List files bound to the turn: {files:[{file_id, name, content_type, size, url}]}. |
GET /v1/files/{file_id} | Download a file (streamed bytes; Content-Disposition filename). |
GET /v1/turns/{turn_id}/deliveries | Webhook deliveries for the turn (see Webhooks). |
end_user_ref is optional. Send your stable customer id on every turn you want attributed to that customer: it is what GET /v1/usage?group_by=end_user and the per-end-user caps key on. There is nothing to register: spend is attributed to a ref from its first turn, and a ref appears in GET /v1/spaces/{slug}/end-users once you set a limit for it. Turns without a ref roll up under null.
Naming a model on a turn
A turn that names a model (or engine) moves the space to that model, not only that turn: it is written as the space’s policy for the orchestrator and every kind of work (code, research, analysis, review, …) before the turn is queued, until a later turn (or any other surface) names a different model or "auto". A turn that names no model keeps it:
curl -s -X POST $BASE/turns -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"space":"'$SLUG'","text":"Review this contract","model":"opus55"}'
modelis a model id fromGET /v1/models(opus55,light,haiku,codex-sol6,codex-luna6,glm) or a raw model id (claude-opus-5-5,gpt-6.1-sol,openai/gpt-6.1-sol, …).engine(claude-code|codex|opencode) disambiguates a raw id;enginealone picks that engine’s first model in catalog order.- It is resolved through the catalog: a retired id reads as its successor (
opus→opus55,gpt-5.6-sol→codex-sol6, …). The resolved id is returned asmodel_overrideonGET /v1/turns/{turn_id}. - A model that is unknown, disabled or outside the space’s allowed models is refused with
400 UNSUPPORTED_MODEL(details.modelslists yours); an admin-only model with403 FORBIDDEN. Nothing is queued. - It stays on the space. The next turns that name the same model run on it with no switch. A turn naming another model moves the space once more. A turn without
model/enginechanges nothing: the policy holds, whoever set it. - To go back to the default, send
"model": "auto". It returns the space’s model to the default policy whatever set it (a turn, the dashboard,PUT /v1/spaces/{slug}/policyor the chat’s/model), as doPUT /v1/spaces/{slug}/policywith{"orchestration": "auto"}andDELETE /v1/spaces/{slug}/policy/orchestration. That turn and the turns after it follow the space’s policy (its per-space override, else the fleet default) again. - Why it stays: a model change is a session switch. Moving to another engine dreams the closing session first. A model held across turns costs that switch once, and the session keeps its prompt cache. Switching back and forth on every turn would pay the switch and lose the cache each time.
- A model that cannot run a particular job right now (a capacity cap, an engine outage) is skipped for that job with the reason, the job runs on the policy’s next choice, and the policy’s model is tried first again on the next job.
- A stateless turn runs in a throwaway sub-space. The model it names ends with that sub-space and never reaches the space it came from.
- An
Idempotency-Keyreplay that names a different model is409 IDEMPOTENCY_CONFLICT, like a different text.
Stateless turns: one job, no history
With "stateless": true the turn runs in a fresh, throwaway sub-space made for it alone: the agent sees only this text, its files and the space’s instructions (manifest, tools, secrets), not the space’s earlier conversation, and the space’s own history is neither read nor advanced. Use it for independent, repeatable jobs (a scan per image, one lookup per request) that should not grow, or be coloured by, a long session.
curl -s -X POST $BASE/turns -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"space":"'$SLUG'","text":"Read the attached scan","file_ids":["'$FILE'"],"stateless":true}'
- Default: off. Send
"stateless": trueon each turn that should run without history. - Nothing else changes for you: the turn belongs to the
spaceyou named (GET /v1/spaces/{slug}/turns, files,end_user_ref, caps),GET /v1/turns/{turn_id}returns"stateless": true, and its cost is billed to the same account and counted in the key’s caps. - The throwaway sub-space is never listed and is archived as soon as the turn ends (done, failed, cancelled or timed out); its files are kept for the retention period, then deleted.
- Setting it up adds well under a second to the turn. An
Idempotency-Keyreplay with a differentstatelessis409 IDEMPOTENCY_CONFLICT.
Live calls: your end user talks to Octo by voice
Your app can put a “Call Octo” button in front of its users: a live voice call with Octo in your space, from inside your app. Your server asks for a call with its key for one end user; you get a short-lived, single-use join_url; your page opens it in a popup (or an iframe with allow="microphone"). The end user needs no account: the link is the only thing that lets them in.
| Method and path | Purpose |
|---|---|
POST /v1/spaces/{slug}/calls → 201 | A join link for one end user. Body {end_user_ref, display_name?, lang?} (end_user_ref is required). Returns {space, end_user_ref, token, join_url, expires_at, max_minutes}. The link opens one call, once, within 5 minutes. |
GET /v1/spaces/{slug}/calls/{call_id} | The call afterwards: state (live | ended), seconds, minutes, energy, end_user_ref, transcript and summary_turn_id (the turn whose result is Octo’s summary of the call). Any key of the account that minted it. |
# your server, when the signed-in user taps "Call Octo"
curl -s -X POST $BASE/spaces/$SLUG/calls -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"end_user_ref":"cust-42","display_name":"Maria","lang":"en"}'
# → {"token":"…","join_url":"https://dash.octodus.com/app-call/…","expires_at":"…","max_minutes":30,…}
// your page: open it from the click itself (popup blockers), then follow it
const w = window.open(joinUrl, "octo-call", "width=420,height=640");
window.addEventListener("message", (e) => {
if (e.data?.source !== "octo-call") return; // {event: "started" | "ended" | "error", call_id}
if (e.data.event === "ended") refreshHistory(e.data.call_id);
});
- Who is calling. The caller is your end user
end_user_refin your account’s namespace, exactly as onPOST /v1/turns: never a person on the platform. Every question Octo works on during the call is an ordinary turn in the space with your key, thatend_user_refandmetadata.channel = "voice_call", so your usage, caps and webhooks see it (filter onmetadata.channelif your webhook should not react to call turns). - Where it goes. Octo answers by voice. What takes longer comes back as the turn’s result (and through your app’s own API, as for any turn); nothing is posted to a chat. When the call ends, its transcript and Octo’s summary are kept under that end user (
GET /v1/spaces/{slug}/calls/{call_id}). - Scope. The link works only in that space and only for that end user, and only while your key reaches the space: a key of another account can never mint one for your space (
403 KEY_NOT_BOUND_TO_SPACE), and revoking the key stops an unused link. A used, expired or unknown link answers404. Each call is one person’s: a voice room opened in the browser (WebRTC) with just that end user and Octo, never anyone else’s call or the space’s shared room. It ends when the end user hangs up or closes the page. - Billing. The call’s voice room (speech recognition, Octo’s voice, active minutes) is billed in energy to the account of the key that minted it, and
energyonGET …/calls/{call_id}shows what it cost so far; the turns Octo runs during it bill like any turn. The mint is refused while the owner is out of energy (402) or a key or end-user cap is reached (429). A call lasts at mostmax_minutes. - Availability. Voice calls are part of the Plus subscription of the space owner: without it the mint answers
403 CALLS_NOT_ENABLED; while the voice room cannot take a call (switched off, or at capacity),503 CALLS_UNAVAILABLE: try again in a minute. - Mint on the server only, right before opening, and never put your API key in the page. Mint a fresh link for every call.
Files: attach to a request and retrieve produced files
Two directions, one store. You can attach files to a turn (the agent sees them like a chat upload), and the agent can produce files that come back to you as authenticated download links. Files stream through the API (Bearer and ownership scoped); nothing is ever public, and an id you don’t own returns 404.
| Method and path | Purpose |
|---|---|
POST /v1/spaces/{slug}/files → 201 | Upload an attachment (multipart/form-data, field file) into a space you can reach. Returns {file_id, name, content_type, size, url}. The upload is unbound until a turn references it. |
POST /v1/turns | Attach uploads by listing their ids: {space, text, file_ids:["<id>", ...]}. Each id must be an unbound upload your account made for that space (else 404 FILE_NOT_FOUND). Max 10 per turn. |
GET /v1/turns/{turn_id} | The turn response carries files:[{file_id, name, content_type, size, source, url}]; source is upload (what you sent) or agent (what it produced). |
GET /v1/files/{file_id} | Download the bytes via the url from the turn or list (streamed; keeps the original filename). |
Attach a file to a request
# 1) upload the bytes into the space → returns a file_id
FID=$(curl -s -X POST $BASE/spaces/$SLUG/files -H "Authorization: Bearer $KEY" \
-F 'file=@./invoice.pdf' | jq -r .file_id)
# 2) send a turn that attaches it (the agent sees invoice.pdf in the space)
curl -s -X POST $BASE/turns -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d '{"space":"'"$SLUG"'","text":"Summarise this invoice","file_ids":["'"$FID"'"]}'
file_id may only be attached by the account that uploaded it, only to the space it was uploaded into, and only once (binding it to a turn consumes it). Upload one file per call; list several file_ids to attach up to 10 to a turn.Retrieve files the agent produced
When the agent creates or sends a file (this needs the files capability, for example "allow":["files", ...]), it comes back on the turn as a link:
GET /v1/turns/turn_ab12...
{
"turn_id": "turn_ab12...", "state": "done", "result": "Here is the report.",
"files": [
{ "file_id": "9f3c...", "name": "report.pdf", "content_type": "application/pdf",
"size": 20481, "source": "agent", "url": "/v1/files/9f3c..." }
]
}
# download it (Bearer + ownership scoped; original filename preserved)
curl -s -L "$BASE/files/9f3c..." -H "Authorization: Bearer $KEY" -o report.pdf
url is a path under /v1: prefix it with https://dash.octodus.com and send the same Authorization: Bearer header. Uploads are capped at 200 MB (413 FILE_TOO_LARGE); an empty upload is 400 INVALID_REQUEST. Give an upload a longer request timeout than a JSON call (the body streams through).File names: UTF-8
File names may be any UTF-8 text (Cyrillic, German umlauts, emoji). Send the name as plain UTF-8 in the multipart filename parameter: curl -F 'file=@./счёт.pdf', fetch with FormData and Python requests on urllib3 2.x all do. The RFC 2231 form filename*=utf-8''... (urllib3 1.x) is not read: the part is then not seen as a file and the upload is 422. It is kept as sent, minus double quotes and line breaks, up to 200 characters, and returned as name on the upload, the turn’s files and the file list. A download carries both forms in Content-Disposition: an ASCII fallback filename="????.pdf" and the exact name as filename*=UTF-8''%D1%81%D1%87%D1%91%D1%82.pdf (RFC 5987). Save under name from the JSON, or read filename* (browsers do), not the ASCII fallback.
Voice input: a recording as the user’s message
Your app can let a user speak instead of typing, or send an audio file. Upload the recording like any attachment, then name it as the turn’s voice_file_id. The platform transcribes it server-side (the same speech-to-text as a Telegram voice message), the turn runs on the transcript as the user’s message, and the recording stays in the space as an attachment the agent can listen to again.
| Step | Call |
|---|---|
| 1. Upload the recording | POST /v1/spaces/{slug}/files (multipart/form-data, field file) → {file_id} |
| 2. Send it as the message | POST /v1/turns {space, voice_file_id, text?, end_user_ref?, ...} → 202. text is optional; if you send it, it follows the transcript (a caption, the context of the screen). |
| 3. Show what was heard | GET /v1/turns/{turn_id} (and GET /v1/spaces/{slug}/turns, the webhooks) carry voice:{file_id, url, name, content_type, status, transcript, duration_sec}. status is pending until the recording is heard (usually a few seconds after the turn starts), then done with the transcript, empty (no speech) or failed. |
# a 5-second voice note recorded in the browser (MediaRecorder gives audio/webm)
FID=$(curl -s -X POST $BASE/spaces/$SLUG/files -H "Authorization: Bearer $KEY" \
-F 'file=@./voice.webm;type=audio/webm' | jq -r .file_id)
curl -s -X POST $BASE/turns -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d '{"space":"'"$SLUG"'","voice_file_id":"'"$FID"'","end_user_ref":"user-42"}'
GET /v1/turns/turn_ab12...
{ "turn_id": "turn_ab12...", "state": "done", "result": "Your order 42 ships tomorrow.",
"voice": { "file_id": "7c1e...", "url": "/v1/files/7c1e...", "name": "voice.webm",
"content_type": "audio/webm", "status": "done",
"transcript": "Where is my order 42?", "duration_sec": 4.8 }, ... }
- Formats: webm/opus (Chrome, Firefox
MediaRecorder), ogg/opus, m4a/mp4 (Safari, iOS), mp3, wav. Matched by the file extension or the upload’s content type; anything else is415 UNSUPPORTED_MEDIA_TYPE. - Size: up to 100 MB per recording (
413 VOICE_TOO_LARGE,details.max_bytes); the upload itself is capped at 200 MB (413 FILE_TOO_LARGE). Long recordings are compressed and transcribed in parts automatically. Send a longer file infile_idsas an ordinary attachment. - Nothing heard: a recording with no speech fails the turn with
error.code: "VOICE_EMPTY"; one that could not be transcribed fails with"TRANSCRIPTION_FAILED". Neither runs the agent. Ask the user to say it again or type it. If you senttextwith the recording, the turn runs on the text instead and the agent is told the recording was not understood. - Billing: transcription is billed in energy per minute of audio to the account that pays for the turn, and is part of the turn’s
energy_used(linked_energy). An empty recording is not billed. - Rules as for attachments: the recording must be an unbound upload your account made for that space (else
404 FILE_NOT_FOUND), it is consumed by the turn, it counts toward the 10 files per turn, and it may not also be listed infile_ids(400). AnIdempotency-Keyreplay with a differentvoice_file_idis409 IDEMPOTENCY_CONFLICT. - Never send your key to the browser. The page records and posts the audio to your backend, which uploads it and creates the turn with its key.
Endpoints: usage and per-end-user caps
| Method and path | Purpose |
|---|---|
GET /v1/usage?group_by=end_user|space|key&scope=account|key | Energy and turn-count rollup: {group_by, breakdown:[{key, energy_used, turns}]}, where key is the end-user ref, space slug or API key id and energy_used sums each turn’s whole cost as GET /v1/turns/{turn_id} shows it. Defaults: group_by=end_user, scope=account (the whole account, for re-billing across your keys); scope=key counts only this key’s turns. |
GET /v1/spaces/{slug}/end-users | End users of the space that have a limit record (set with the PATCH below): {end_users:[{end_user_ref, total_limit_energy, spent_energy, remaining_energy}]}. Use /v1/usage?group_by=end_user for the energy of every ref. |
PATCH /v1/spaces/{slug}/end-users/{ref} | Set an end user’s cumulative cap in energy. Body { "total_limit_energy": 50 } (null = uncapped, a negative value is 400). Creates the record if needed; returns {slug, end_user_ref, total_limit_energy}. |
Tasks: follow longer work
When a request is bigger than one reply (“research our competitors and write a report”), the agent captures it as a task in the space and works on it across turns, often through sub-agents. A turn that captured or worked on a task lists it in tasks, on GET /v1/turns/{turn_id} and in the turn.completed event. Follow it from there by task_id.
| Method and path | Purpose |
|---|---|
GET /v1/tasks?space=&status=&q=&limit=50&cursor= | Tasks in the spaces this key reaches (or in one space), most recently updated first. status is open, done, failed, cancelled or all (default); q searches titles. Returns {tasks:[…], next_cursor}; pass next_cursor as cursor for the next page (null on the last one). Max limit 200. |
GET /v1/tasks/{task_id} | One task: status, description, spend in energy, artifacts and links (below). |
GET /v1/tasks/{task_id}/updates?after=&limit= | The task’s timeline, oldest first: what happened to it and what the agents working on it reported. Returns {updates:[…], has_more, next_cursor}; pass next_cursor as after to get only what is new. |
GET /v1/tasks/3f2c...
{
"task_id": "3f2c...", "space": "api-1a2b...", "number": 7,
"title": "Competitor report", "status": "open", // open | done | failed | cancelled
"description": "Compare the top 5 competitors on price and features ...",
"tags": ["research"], "priority": null,
"in_progress": true, // an agent is working on it right now
"origin_turn_id": "turn_ab12...", // the turn that captured it
"created_at": "...", "updated_at": "...", "closed_at": null, "closed_reason": null,
"spend": {"energy": 5.0, "turns": 3, "active_seconds": 412.5,
"first_activity_at": "...", "last_activity_at": "..."},
"artifacts": [{"name": "report.pdf", "kind": "document", "mime": "application/pdf",
"size": 48213, "url": "/v1/files/...", "created_at": "..."}],
"links": {"dashboard": "https://dash.octodus.com/status?space=...&task=7"}
}
GET /v1/tasks/3f2c.../updates?after=te_9b1...
{"updates": [
{"id": "sj_42", "type": "agent.progress", "status": "running", "text": "3 of 5 competitors done", "at": "..."},
{"id": "te_4c0...", "type": "closed", "status": null, "text": "report delivered", "at": "..."}],
"has_more": false, "next_cursor": "te_4c0..."}
- Lifecycle.
open→done,failedorcancelled. A closed task can be reopened when new work starts on it, so treatstatusas current state, not a one-way flag. - Update types. From the task:
created,comment,assigned,delegated(an agent took it on),advanced,mentioned,reopened,closed. From the agents working on it:agent.queued,agent.spawn,agent.progress,agent.report,agent.error. Ignore types you do not know; new ones may be added. - Spend is in energy and covers every turn and sub-agent that worked on the task.
- Artifacts with a
urlof/v1/files/{file_id}download with your key; an external link has an absolute URL; a file delivered only inside a chat hasurl: null. - Poll or be pushed. Poll
/updates?after=every 30 to 60 s, or subscribe a webhook totask.created,task.updated(one event per new update, with the update indata.update),task.completedandtask.failed. Every task event’sdatais the task asGET /v1/tasks/{task_id}returns it. - A task outside this key’s spaces answers
404 TASK_NOT_FOUND; an unknownafterorcursoranswers400 INVALID_CURSOR.
Webhooks: reference
The step-by-step guide is Knowing a turn finished; this is the reference. A webhook belongs to the API key that registered it and only ever receives that key’s events. Bind it to one space, or leave space out to cover every space the key reaches; when a key has webhooks bound to a space, only those fire for that space. Max 10 per key (409 LIMIT_EXCEEDED). You can also manage them in the dashboard: Integrations → API Keys → your key → Webhooks.
| Method and path | Purpose |
|---|---|
POST /v1/webhooks → 201 | Register. Body {url, events?, space?, secret?, active?}. Returns the webhook and its secret once (generated when omitted, 16 to 256 characters when you set it; never on GET). |
GET /v1/webhooks | This key’s webhooks (no secrets) plus the events catalogue. |
GET /v1/webhooks/{webhook_id} | One webhook: {id, url, events, space, active, disabled_reason, consecutive_failures, last_success_at, last_failure_at, disabled_at, created_at, updated_at}. |
PATCH /v1/webhooks/{webhook_id} | Edit url/events/space/active. rotate_secret:true (or your own secret) rotates it; the new secret is returned once. active:true re-enables a webhook the platform switched off. |
DELETE /v1/webhooks/{webhook_id} | Remove it; deliveries still queued for it are dropped. |
GET /v1/webhooks/{webhook_id}/deliveries?limit=50 | Delivery log, newest first (max 200): id, event, event_id, turn_id, task_id, status, attempt, last_status_code, last_error, next_attempt_at, delivered_at. status is pending, delivered, failed (every attempt used) or disabled. |
POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver → 202 | Send an event again to the webhook’s current URL and secret: a new delivery with the same event id and payload (409 WEBHOOK_INACTIVE while the webhook is off). |
GET /v1/turns/{turn_id}/deliveries | Every delivery for one turn, including a per-turn webhook_url. |
URL rules. https to a public address only, no credentials in the URL. Private networks, loopback, link-local and cloud-metadata ranges are refused with 400 INVALID_REQUEST. The host is checked when you register it and resolved again before every delivery, and the delivery connects to exactly the address that passed the check.
Events. Leave events out to get every turn event; task events are sent only to a webhook that names them.
| Event | When | data |
|---|---|---|
turn.completed | The turn reached done | The turn, exactly as GET /v1/turns/{turn_id} returns it (result, files, energy_used, tasks), plus space, status, end_user_ref |
turn.failed | The turn reached failed | Same shape; error is {code, message} |
turn.cancelled | You cancelled it | Same shape |
turn.queued, turn.running | Accepted; picked up by the agent | Same shape, no result yet |
task.created | The agent captured a task in the space | The task, exactly as GET /v1/tasks/{task_id} returns it |
task.updated | Each new entry on the task’s timeline | The task, plus update: the new entry as GET /v1/tasks/{task_id}/updates returns it |
task.completed | The task was closed as done | The task |
task.failed | The task was closed as failed or cancelled | The task; status says which |
Delivery. POST <your url>, Content-Type: application/json, User-Agent: Octo-Webhooks/1.0, and:
| Header | Value |
|---|---|
X-Octo-Event | The event type, for example turn.completed |
X-Octo-Delivery | This delivery attempt series’ id (whd_…); a redelivery gets a new one |
X-Octo-Timestamp | Unix seconds when this attempt was signed |
X-Octo-Signature | t=<timestamp>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<timestamp>.<raw body>") |
Older receivers may read the same values from X-OOIH-Event, X-OOIH-Delivery and X-OOIH-Signature; they are sent too.
{
"id": "evt_9b2e...", // the event id: dedupe on it (a redelivery keeps it)
"type": "turn.completed",
"api_version": "v1",
"created_at": "2026-09-29T10:00:00+00:00",
"data": { // = GET /v1/turns/{turn_id} + space, status, end_user_ref
"turn_id": "turn_ab12...", "space": "api-1a2b...", "status": "done", "state": "done",
"end_user_ref": "customer-42", "result": "...", "result_truncated": false, "error": null,
"files": [{"file_id": "...", "name": "report.pdf", "url": "/v1/files/..."}],
"tasks": [], "energy_used": 0.123, "usage": {...},
"metadata": {}, "created_at": "...", "started_at": "...", "completed_at": "..."
}
}
Retries. Any 2xx within 10 s is success. Anything else, or no answer in 10 s, is retried after 30 s, 2 min, 5 min, 15 min and 40 min (6 attempts over about an hour); after that the delivery is failed and you can replay it with redeliver. Ordering is best effort.
Automatic switch-off. 410 Gone switches the webhook off at once (disabled_reason: "gone"). After 10 deliveries in a row that fail every attempt it is switched off too (disabled_reason: "failing"). consecutive_failures, last_success_at and last_failure_at show how it is doing; a success resets the count. A switched-off webhook gets nothing new until you send PATCH {"active": true}. Polling is unaffected throughout, and a failing endpoint never delays or fails a turn.
Per-turn webhook. POST /v1/turns also takes webhook_url (and an optional webhook_secret, 16 to 256 characters) for a one-off callback for that turn’s events, on top of your registered webhooks. Same URL rules, same signature. Without webhook_secret the 202 carries a generated one, shown once. Prefer a registered webhook; the per-turn form suits a job whose callback URL is unique to the job.
Turn lifecycle
queued → running → done (result text ready)
→ failed (error set)
queued | running → cancelled (you cancelled before it finished)
Poll GET /v1/turns/{turn_id} until state is terminal: done | failed | cancelled (the terminal success state is done, not succeeded). Response:
{
"turn_id": "turn_ab12...",
"state": "done",
"current_activity": "...", // live progress hint while running
"result": "the agent's reply text",
"result_truncated": false, // true if the reply exceeded 1 MB
"error": null, // { "code": "...", "message": "..." } when state=failed
"usage": { "input_tokens": ..., "cached_input_tokens": ..., "output_tokens": ...,
"reasoning_output_tokens": ..., "total_tokens": ...,
"provider": "...", "model": "...",
"turn_energy": ..., "linked_energy": ..., "linked_calls": ... },
// null until the turn reports usage
"energy_used": 0.123, // null until billed; the whole turn, in energy
"stateless": false, // see Stateless turns
"metadata": { ...echoed back... },
"created_at": "...", "started_at": "...", "completed_at": "...",
"files": [ ... ], // see Files
"tasks": [ {"task_id": "...", "number": 7, "title": "...", "status": "open"} ] // see Tasks
}
energy_used and usage cover the whole turn: the agent’s own run plus every call it caused: a sub-agent it handed the work to, the tools and memory passes they ran (turn_energy is the agent’s own run, linked_energy and linked_calls the rest). A sub-agent can still report for a moment after the turn is done, so the figure may rise slightly on the next read; the key’s daily and monthly caps count the same calls, live. energy_used is the energy actually charged, every multiplier included (see Billing in energy).
Turns are asynchronous: poll every 1 to 2 s with capped backoff, or skip polling with a webhook. Expect seconds to minutes, not milliseconds; see Timeouts and retries.
Timeouts and retries
Poll or be pushed. Both work for every turn. Polling GET /v1/turns/{turn_id} every 1 to 2 s with capped backoff (up to 10 s) is the simplest; a webhook saves the polls in production (then poll once for a turn with no final event after 10 minutes). Neither carries your data: that is in your app already (see Build on your app’s API).
- Never inside your own caller’s request. An app answers its user
202at once and shows the request’s progress from its own database: the statuses the space sets. A request that waits for the turn (a browser upload that returns only with the answer) fails whenever a turn outlasts its budget, and raising the timeout only moves that line. - Per request: every call returns at once (the turn runs in the background), so a 30 s HTTP timeout is plenty; allow longer for a large upload.
- Per turn: a script that waits gives a turn at least 180 s. Most finish in seconds to a minute or two; files, research, tools or a sub-agent take longer. Background work (reading a scan, recognising a document) gets no deadline under hours: show “still running” rather than “failed”. Giving up only stops your wait: the turn keeps running and is billed;
POST /v1/turns/{turn_id}/cancelif you no longer want it. - A network error is not a result. Retry every call with backoff. A
POST /v1/turnsthat timed out may have started the turn: keep your job running and re-send it later with the sameIdempotency-Keyand body, which returns that turn. A failed poll is skipped, not counted. Only the turn’s ownfailedorcancelledstate, or a4xxthe table below says not to retry, fails your job. - A failed turn is sent again, not shown as the request’s error. A turn
failedwithSUBAGENT_BLOCKEDorSUBAGENT_PARTIAL(a worker refused or did not finish) is retried: send “Handle request 42” again with backoff, with a newIdempotency-Keyper attempt. That is safe: the space continues the request from the statuses it set in your app. Nothing is parsed from the answer; after the last attempt, mark the request needs review. - Short-lived connections. Send
Connection: close, or keep idle sockets under 5 s, so one stalled keep-alive socket does not hang your next call. - The platform’s own limit: a turn that makes no progress for 2 hours is closed as
failedwitherror.code: "stale_reaped"and is not billed.
| Response | Retry? |
|---|---|
GET (poll, list, download) answered 502, 503, 504 or a network error | Yes, always safe: a read changes nothing. Back off 1, 2, 4 s and keep going. |
POST /v1/turns answered 5xx, a network error or a timeout | Yes, with the same Idempotency-Key and body: you get the original turn, never a second one (see Idempotency). Without the header a retry can start a second paid turn. |
429 QUOTA_EXCEEDED with Retry-After | Never before Retry-After seconds. A daily, monthly or windowed cap does not clear sooner; retrying early is refused again. |
429 QUOTA_EXCEEDED, reason: "end_user_limit" (no Retry-After) | No. Raise that end user’s total_limit_energy first. |
402 INSUFFICIENT_CREDITS | No. Top up the account first. |
Other 4xx | No. Fix the request; error.code says what is wrong. |
Idempotency
Send a unique Idempotency-Key header on POST /v1/turns (a UUID per logical message):
- Keys are scoped to the API key that sends them and do not expire.
- Re-sending the same key with the same
spaceandtextreturns 202 with the original turn’sturn_idand its currentstate(no duplicate, no second charge). - Re-sending the same key with a different
spaceortextreturns409 IDEMPOTENCY_CONFLICT. - After a create that timed out, re-sending the same request is how you find the turn: it returns the one that started, or starts it now.
- Without it, a network-blip retry creates a second paid turn. Always send it.
Tools and MCP
PUT /v1/spaces/{slug}/tools body (full replace; use PATCH to change one field):
{
"allow": ["web", "image", "files"], // built-in allowlist; null = unrestricted
"mcp_servers": [ // your own tools, exposed over MCP
{ "name": "acme", "url": "https://mcp.acme.com",
"headers": { "Authorization": "Bearer ${secret:ACME_MCP_TOKEN}" } }
]
}
An MCP server is attached to the space's agent in every channel, on every engine, and reached by its
sub-agents. The url must be https:// on a public host (or one of this space's own
app deployments). A credential never goes in the config: store it with
PUT /v1/spaces/{slug}/secrets and reference it in a header as ${secret:NAME};
it is substituted on the platform side when the agent starts, and a credential-shaped header
(Authorization, Cookie, X-Api-Key, …) without a reference is refused
with 400.
Built-in capability keys (live list: GET /v1/tools/catalog):
| Key | Capability | Notes |
|---|---|---|
web | Web and browser research | |
image | Image generation | billable |
media | Audio and video generation | billable |
transcription | Audio and video transcription | |
gdrive | Google Drive | |
yandex_disk | Yandex Disk | Yandex spaces only |
yandex_docs | Yandex Documents and Tables | Yandex spaces only |
seo | SEO tools | billable |
code | Code execution | |
files | File creation and delivery | |
research | Deep research | |
lizard | Lizard app deployment | billable |
higgsfield | Premium media (Higgsfield: video, image-to-video, upscale) | billable, gated |
elevenlabs | Premium voice (ElevenLabs) | billable, gated |
An unknown key is 400 INVALID_REQUEST with details.allowed. Gated keys additionally need the owner’s balance to clear the feature’s funding check at turn time. Least privilege: allow only what the space needs.
PUT /v1/spaces/{slug}/secret); they are encrypted at rest and injected server-side when a tool calls your app. The manifest is the durable “how to drive my app” instruction: set it once, don’t re-send it per turn. Tools work on both engines (claude-code and opencode).Results go into your app, not into the reply
The space works through your app’s API (see Build on your app’s API): the operations a person uses, with the delegate credential you handed it.
- Your API checks every write as it does for a person (the schema, then domain checks such as line sums = total) and refuses a bad one with a
4xxand a reason. The agent reads the reason, fixes the data and writes again, then re-reads what it wrote. - Long work (recognising a document) is a request in your app, with the status the space sets as it goes: in work, done, needs review, or rejected with the reason. Your UI shows those statuses from your own database. Say in
instructions(or the manifest) what the space does when it cannot finish, for example set needs review with the reason. - So you write no JSON-from-text parser, no Octodus-specific result endpoint and no
submit_resulttool: a person can do the same job with the same requests.
result is a human-readable summary. Show it to a person or log it, but do not parse it as JSON or scrape data from it. Your data is what the space wrote into your app through its API during the turn (files the agent produced are listed on the turn; fetch them via GET /v1/turns/{id}/files).Billing in energy
The API reports every cost, cap and balance in energy, the unit the account is topped up and charged in, never in a currency. energy_used is what the turn actually cost the account, every multiplier included; the same number is what your key’s caps count and what the account’s balance goes down by.
- Every turn bills the account that owns the space its
energy_used. Octodus bills you in aggregate; you re-bill your own users from theend_user_refbreakdown inGET /v1/usage. - Out of credits (the owner’s balance is exhausted) →
402 INSUFFICIENT_CREDITSwith{reason:"owner_balance_exhausted", space}. Top up the account to continue. This is not a rate limit, so retrying without topping up will not clear it. - Per-key daily cap (default 50 energy per day, resets at 00:00 UTC) →
429 QUOTA_EXCEEDEDwith{reason:"daily_cap", spent_today_energy, cap_energy, remaining_energy, resets_at}and aRetry-Afterheader (seconds to reset). - Per-key monthly cap (optional, in energy) →
429 QUOTA_EXCEEDEDwith{reason:"monthly_cap", spent_month_energy, cap_energy, remaining_energy}andRetry-After(seconds to the next calendar month, UTC). - Per-end-user cumulative cap (
total_limit_energy, default none) →429 QUOTA_EXCEEDEDwith{reason:"end_user_limit", end_user_ref, limit_energy, spent_energy, remaining_energy}once that end user’s lifetime energy in the space reaches it. There is noRetry-After: raise the limit to grant more. - Windowed usage caps (configured by the owner in the dashboard, per space or end user) →
429 QUOTA_EXCEEDEDwith{reason:"usage_window", scope, end_user_ref, window, metric}, pluslimit_energy,spent_energyandremaining_energyfor an energy cap (aturnscap carriesamountandspentas turn counts), and aRetry-Afterheader. - Caps are checked when a turn is created, against energy already charged;
remaining_energyin a refusal is therefore0. - Every error
reasonis a stable machine string; branch onerror.codeanderror.details.reasonrather than the message text.
HTTP/1.1 429 Too Many Requests
Retry-After: 30512
X-Trace-Id: 5f1c...
{"error": {"code": "QUOTA_EXCEEDED", "message": "Daily energy cap reached",
"details": {"reason": "daily_cap", "spent_today_energy": 50.4, "cap_energy": 50.0,
"remaining_energy": 0.0, "resets_at": "2026-09-29T00:00:00+00:00"},
"trace_id": "5f1c..."}}
billed_cost_usd (use energy_used), usage.turn_cost_usd / usage.linked_cost_usd (turn_energy / linked_energy), spend_usd in GET /v1/usage (energy_used), total_limit_usd / spent_usd on end users (total_limit_energy / spent_energy), and spent_today_usd, spent_month_usd, cap_usd, limit_usd in a 429 (the *_energy fields), plus amount / spent on an energy usage_window refusal. PATCH .../end-users/{ref} still reads {"total_limit_usd"} when total_limit_energy is absent. After 2026-10-28 only the energy fields remain; move to them now.Rate limits and payload limits
Request rate. The API does not apply a per-request or per-second rate limit today. Usage is bounded by money instead: the owner’s balance (402) and the spend caps above (429 QUOTA_EXCEEDED). Be a good citizen anyway: poll a turn every 1 to 2 s with capped backoff (or use webhooks), and treat any 429 as retryable only after its Retry-After, so your client keeps working if a request-rate limit is added later.
| Limit | Value | When exceeded |
|---|---|---|
metadata on a turn | 8 KB of JSON (opaque, echoed back) | 400 INVALID_REQUEST |
file_ids per turn | 10 | 400 INVALID_REQUEST |
| Upload size | 200 MB per file | 413 FILE_TOO_LARGE |
Voice recording (voice_file_id) | 100 MB; webm, ogg, m4a/mp4, mp3, wav | 413 VOICE_TOO_LARGE / 415 UNSUPPORTED_MEDIA_TYPE |
result text | 1 MB | truncated, with result_truncated: true |
| Webhooks per key | 10 | 409 LIMIT_EXCEEDED |
| Webhook secret | 16 to 256 characters | 400 INVALID_REQUEST |
limit on list endpoints | default 50, max 200 | clamped |
| Per-key daily energy | default 50 energy, up to 10,000 energy | 429 QUOTA_EXCEEDED |
Error codes
Errors use the envelope {"error":{"code","message","details","trace_id"}}. Two responses come from the framework instead and use {"detail": ...}: a body that fails schema validation (wrong type, malformed JSON, missing multipart file) is 422, and a browser cross-site write is 403.
| HTTP | code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | Missing space/text (a turn needs text or voice_file_id), bad group_by, unknown tool key, metadata over 8 KB, more than 10 file_ids, empty upload, bad webhook URL, events or secret, or an unknown engine/model. |
| 400 | INVALID_KIND / UNKNOWN_MODEL | A policy write named an unknown kind, or a model id that is not enabled. |
| 400 | UNSUPPORTED_MODEL | POST /v1/turns named a model/engine this space cannot run (unknown, disabled, or outside its allowed models). details.models lists the models you can use. |
| 400 / 422 | RESERVED_SECRET_NAME | A named secret used a reserved name (see Named secrets). |
| 401 | INVALID_KEY | Missing, malformed, unknown or revoked key (also when a space-bound key’s owner no longer owns the space). |
| 402 | INSUFFICIENT_CREDITS | The account paying for the space is out of credits. Top up to continue; not a rate limit. details.reason = "owner_balance_exhausted". |
| 403 | KEY_NOT_BOUND_TO_SPACE | On POST /v1/turns: the key may not drive that space (bound to another space, the account doesn’t own it, or an own key didn’t create it). |
| 403 | FORBIDDEN | This key may not create spaces, or a model is admin-only for this account (for example one named on POST /v1/turns, POST /v1/spaces or PATCH /v1/spaces/{slug}). |
| 403 | CALLS_NOT_ENABLED | POST /v1/spaces/{slug}/calls: voice calls are not part of the space owner’s plan (Plus). |
| 503 | CALLS_UNAVAILABLE | POST /v1/spaces/{slug}/calls: the voice room cannot take a call right now (switched off, or at capacity); try again in a minute. |
| 403 | COMMANDER_IS_PERSONAL | The target is a personal Commander space, which is never reachable over the API. |
| 403 | OPERATOR_ONLY | A policy write named a kind a space cannot set; details.who says who can. |
| 403 | INELIGIBLE | A policy write named a model this space may not use (admin-only, or outside its allowed models). |
| 400 | INVALID_CURSOR | A cursor or after that is not one this API returned (or has expired). |
| 404 | SPACE_NOT_FOUND / APP_API_NOT_FOUND / TURN_NOT_FOUND / TASK_NOT_FOUND / FILE_NOT_FOUND / SECRET_NOT_FOUND / WEBHOOK_NOT_FOUND / DELIVERY_NOT_FOUND | Not found, or not visible to this key (existence isn’t leaked). |
| 409 | IDEMPOTENCY_CONFLICT | Idempotency-Key reused with a different space or text. |
| 409 | CONFLICT | An app API name is already used by an MCP server registered through /tools. |
| 409 | ALREADY_TERMINAL | Cancel on an already finished turn. |
| 409 | LIMIT_EXCEEDED | The key already has 10 webhooks. |
| 409 | WEBHOOK_INACTIVE | Redeliver on a disabled webhook; re-activate it first. |
| 413 | FILE_TOO_LARGE | Uploaded file over the 200 MB limit. |
| 413 | VOICE_TOO_LARGE | The voice_file_id recording is over 100 MB (see Voice input). |
| 415 | UNSUPPORTED_MEDIA_TYPE | The voice_file_id upload is not audio in a supported format. |
| 429 | QUOTA_EXCEEDED | Daily or monthly key cap, per-end-user cap, or a windowed usage cap reached (details.reason says which; amounts in energy). Carries a Retry-After header when a reset horizon exists; do not retry before it. |
Worked example (shell)
BASE=https://dash.octodus.com/v1
KEY=octo_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 1) create an isolated space and hand it your app's API, with the delegate token
# your app issued for user 42 (a key scoped to "only spaces this key creates"
# can never touch anything but the spaces it makes)
SLUG=$(curl -s -X POST $BASE/spaces -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"title":"Acme orders for user 42","app_api":{"name":"acme","base_url":"https://api.acme.com/v1","docs_url":"https://api.acme.com/v1/openapi.json","credentials":{"ACME_API_TOKEN":"acme_delegate_..."},"acts_for":"[email protected]"}}' | jq -r .space_id)
# 2) (optional) how the agent behaves (the "App API: acme" block stays) + built-ins
curl -s -X PUT $BASE/spaces/$SLUG/manifest -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"manifest":"You work Acme order requests. An unreadable page: set needs_review with the reason."}'
curl -s -X PATCH $BASE/spaces/$SLUG/tools -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"allow":["web","files"]}'
# 3) send the trigger for one of your end users (idempotent); the space claims
# request 1001 and writes its lines and status into your app
TURN=$(curl -s -X POST $BASE/turns -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d '{"space":"'"$SLUG"'","end_user_ref":"acme-user-42","text":"Handle request 1001"}' | jq -r .turn_id)
# 4) (a script) wait until terminal: state, error, energy_used. The data is in your app
while :; do
R=$(curl -s $BASE/turns/$TURN -H "Authorization: Bearer $KEY")
S=$(echo "$R" | jq -r .state)
[ "$S" = done ] || [ "$S" = failed ] || [ "$S" = cancelled ] && { echo "$R" | jq .; break; }
sleep 1.5
done
# 5) how much energy did acme-user-42 use?
curl -s "$BASE/usage?group_by=end_user" -H "Authorization: Bearer $KEY"
# → {"group_by":"end_user","breakdown":[{"key":"acme-user-42","energy_used":1.84,"turns":1}]}
Common mistakes
- Reading data out of the turn
result. It’s free text for a person. The space writes the data into your app through your API (see Build on your app’s API). - An Octodus-only result endpoint or
submit_resulttool. Give the space the operations a person uses, with a delegate credential; a person must be able to do the same job without Octodus. - Waiting on
succeeded. The terminal success state isdone. - No
Idempotency-Key. A retry then double-charges. Always send one per logical message. - Retrying a cap
429at once. Wait theRetry-Afterseconds; an end-user cap needs a higher limit, not a retry. - Making your own caller wait for a turn. Answer
202at once and show the statuses the space sets in your app; never raise a request timeout to fit a turn. - Giving up on a turn too early. Wait at least 180 s; a turn you stop waiting for still runs and is billed.
- Failing a job on one timeout. A create or a poll that timed out is retried; a timed-out create is re-sent with the same
Idempotency-Key, never marked failed. - Reading the deprecated
*_usdfields. They go away on 2026-10-28; readenergy_usedand the other*_energyfields. - Polling a turn with a different key. A turn is visible only to the key that created it.
- Calling the API from a browser. Cross-site writes are refused; call from your backend.
- Account-wide key when you meant scoped. Use “Only spaces this key creates” so the key can’t reach your existing spaces.
- Relying on
external_refin the create body. It isn’t stored today; keep your ownyour_user_id → space_idmap from the returned slug. - Putting secrets in the prompt. Store them as named secrets; the model only ever sees tool results, never the credential.
- Naming a secret
OPENAI_API_KEY. Engine credential names are reserved; use a prefixed name such asAPP_OPENAI_API_KEY. - Showing a failed turn as the request’s error. Read
error.code; forSUBAGENT_BLOCKEDorSUBAGENT_PARTIALsend the request again (the space continues from your app’s statuses). - Polling when you could be pinged. Register a webhook for “turn finished”, and verify
X-Octo-Signatureagainst the raw body, not re-serialized JSON. - Doing the work before answering the webhook. Answer
2xxfirst, then process; a slow answer counts as a failure and is sent again. - Not deduping on the event
id. A retry or a redelivery sends the same event again.