Octodus APIAll documentation

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.

Base https://dash.octodus.com/v1 Auth Bearer octo_live_… OpenAPI /api/openapi.json

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

ItemValue
Base URLhttps://dash.octodus.com/v1
Auth headerAuthorization: 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 typeapplication/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.

Call the API from your server, not from a browser. CORS only admits the dashboard’s own origins, and any state-changing request that carries a foreign 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:

ChoiceScopeWhat the key can control
Only spaces this key creates (default, recommended)ownOnly the spaces it provisions with POST /v1/spaces. It can never touch your existing (Telegram or other) spaces.
One specific spacespaceExactly one space slug you name, whatever its origin. Any other slug answers 404 SPACE_NOT_FOUND.
All my spacesaccountEvery 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 /deliveries answer 404 TURN_NOT_FOUND for 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.
Prefer “Only spaces this key creates” for a service or integration key: a leaked key can then reach only the spaces it made.

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:

  1. 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_result tool.
  2. 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_api below). 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 a 4xx and a reason, exactly as you would for a person.
  3. Send only the trigger. POST /v1/turns {"space": "...", "text": "Handle request 42"} with an Idempotency-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_api creates no space: 400 INVALID_REQUEST for an http URL, 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_NAME for OOIH_* or engine names such as OPENAI_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}/manifest keeps 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 pathPurpose
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
HowWe POST a signed “turn finished” event to your HTTPS endpointYou call GET /v1/turns/{turn_id} until the state is final
Use it forProduction backendsScripts, prototypes, a backend that cannot take inbound calls
PayloadThe 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:

  1. 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.
  2. Register it once per API key and store the secret from 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
  3. Send turns as usual, with an Idempotency-Key, and store the returned turn_id next to your own record. If you need your own id back, put it in metadata; it is echoed in every event.
  4. In your endpoint: verify X-Octo-Signature over the raw request body, answer 2xx within 10 s, then process. Skip an event whose id you have already processed: a retry sends the same event again. turn.completed carries result (text for a person), files and energy_used; turn.failed carries error: send the request again (see Timeouts and retries).
  5. 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.
  6. Watch its health. GET /v1/webhooks shows active, disabled_reason and consecutive_failures. We switch off a webhook that keeps failing (see reference); fix the endpoint, then PATCH {"active": true}, and replay what it missed with redeliver.

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 pathPurpose
POST /v1/spaces → 201Create 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/spacesList 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=50Recent turns for a space from any of your keys, newest first (max 200): {turns:[…]}.
PUT /v1/spaces/{slug}/manifestSet the durable manifest. Body { "manifest": "..." } → {ok, slug}.
GET /v1/spaces/{slug}/manifestRead it: {slug, manifest}.
PUT /v1/spaces/{slug}/limitsThe 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}/limitsRead it: {daily_energy, set_by, spent_today_energy, remaining_energy, resets_at, key_daily_cap_energy, effective_daily_energy}.
PUT /v1/spaces/{slug}/secretStore 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}/secretsUpsert named secrets (environment variables your tools read). See Named secrets.
GET /v1/spaces/{slug}/secretsList 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}/toolsReplace the toolset (see Tools and MCP).
PATCH /v1/spaces/{slug}/toolsMerge-update the toolset: only the fields you send (allow, mcp_servers) change. Returns {ok, slug, allow, mcp_servers}.
GET /v1/spaces/{slug}/toolsRead 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/catalogThe built-in capability keys you may allowlist: {tools:[{key, label, billable?, gated?, yandex?}]}.
GET /v1/spaces/{slug}/policyThe 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}/policySet 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/modelsThe model catalog (id, label, eligible kinds, tier) plus kinds, settable_kinds and the live fleet_default policy.
Not available yet: a single-space 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 in skipped_invalid. Platform-managed names come back in skipped_system.
  • Refused outright (nothing stored): names starting with OOIH_ (422 RESERVED_SECRET_NAME); names the AI engines read for their own configuration, such as OPENAI_*, ANTHROPIC_*, GEMINI_*, OPENROUTER_* (422 RESERVED_SECRET_NAME, store it as APP_OPENAI_API_KEY instead); bot token names such as TELEGRAM_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), plus unlabelled (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/model follows the platform routing policy. The table changes as models are added, so read it live: GET /v1/models returns it as fleet_default, a map of kind to candidate model ids, first choice first (null when the platform could not be asked right now).
  • Per-space override: a space may override orchestration with PUT /v1/spaces/{slug}/policy (or the owner on the dashboard’s Policies tab). The override is recorded with its provenance (set via api or dashboard); an API space keeps its own model only when one was set this way. When it takes effect is the switch you choose; see Now or after the next dream. The coding kinds and the fleet table are operator levers and are refused with OPERATOR_ONLY.
  • The space’s model is a policy, not a pin: a model set for the space (the orchestration override, the dashboard picker, a turn’s model, 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 in kept. Nothing sits above the policies; see Naming a model on a turn. model_state.configured and policy_source say 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 as model_state.pending with waits_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.rolled says 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.

Deprecated: 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 pathPurpose
POST /v1/turns → 202Send 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}/cancelCancel a non-terminal turn; returns the turn with state:"cancelled" (else 409 ALREADY_TERMINAL).
GET /v1/turns/{turn_id}/filesList 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}/deliveriesWebhook 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"}'
  • model is a model id from GET /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; engine alone 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 as model_override on GET /v1/turns/{turn_id}.
  • A model that is unknown, disabled or outside the space’s allowed models is refused with 400 UNSUPPORTED_MODEL (details.models lists yours); an admin-only model with 403 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/engine changes 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}/policy or the chat’s /model), as do PUT /v1/spaces/{slug}/policy with {"orchestration": "auto"} and DELETE /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-Key replay that names a different model is 409 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": true on each turn that should run without history.
  • Nothing else changes for you: the turn belongs to the space you 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-Key replay with a different stateless is 409 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 pathPurpose
POST /v1/spaces/{slug}/calls → 201A 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_ref in your account’s namespace, exactly as on POST /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, that end_user_ref and metadata.channel = "voice_call", so your usage, caps and webhooks see it (filter on metadata.channel if 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 answers 404. 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 energy on GET …/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 most max_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 pathPurpose
POST /v1/spaces/{slug}/files → 201Upload 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/turnsAttach 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"'"]}'
Attachments are per account, per space, single use: a 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
A 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.

StepCall
1. Upload the recordingPOST /v1/spaces/{slug}/files (multipart/form-data, field file) → {file_id}
2. Send it as the messagePOST /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 heardGET /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 is 415 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 in file_ids as 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 sent text with 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 in file_ids (400). An Idempotency-Key replay with a different voice_file_id is 409 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 pathPurpose
GET /v1/usage?group_by=end_user|space|key&scope=account|keyEnergy 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-usersEnd 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 pathPurpose
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, failed or cancelled. A closed task can be reopened when new work starts on it, so treat status as 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 url of /v1/files/{file_id} download with your key; an external link has an absolute URL; a file delivered only inside a chat has url: null.
  • Poll or be pushed. Poll /updates?after= every 30 to 60 s, or subscribe a webhook to task.created, task.updated (one event per new update, with the update in data.update), task.completed and task.failed. Every task event’s data is the task as GET /v1/tasks/{task_id} returns it.
  • A task outside this key’s spaces answers 404 TASK_NOT_FOUND; an unknown after or cursor answers 400 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 pathPurpose
POST /v1/webhooks → 201Register. 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/webhooksThis 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=50Delivery 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 → 202Send 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}/deliveriesEvery 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.

EventWhendata
turn.completedThe turn reached doneThe turn, exactly as GET /v1/turns/{turn_id} returns it (result, files, energy_used, tasks), plus space, status, end_user_ref
turn.failedThe turn reached failedSame shape; error is {code, message}
turn.cancelledYou cancelled itSame shape
turn.queued, turn.runningAccepted; picked up by the agentSame shape, no result yet
task.createdThe agent captured a task in the spaceThe task, exactly as GET /v1/tasks/{task_id} returns it
task.updatedEach new entry on the task’s timelineThe task, plus update: the new entry as GET /v1/tasks/{task_id}/updates returns it
task.completedThe task was closed as doneThe task
task.failedThe task was closed as failed or cancelledThe task; status says which

Delivery. POST <your url>, Content-Type: application/json, User-Agent: Octo-Webhooks/1.0, and:

HeaderValue
X-Octo-EventThe event type, for example turn.completed
X-Octo-DeliveryThis delivery attempt series’ id (whd_…); a redelivery gets a new one
X-Octo-TimestampUnix seconds when this attempt was signed
X-Octo-Signaturet=<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 202 at 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}/cancel if you no longer want it.
  • A network error is not a result. Retry every call with backoff. A POST /v1/turns that timed out may have started the turn: keep your job running and re-send it later with the same Idempotency-Key and body, which returns that turn. A failed poll is skipped, not counted. Only the turn’s own failed or cancelled state, or a 4xx the table below says not to retry, fails your job.
  • A failed turn is sent again, not shown as the request’s error. A turn failed with SUBAGENT_BLOCKED or SUBAGENT_PARTIAL (a worker refused or did not finish) is retried: send “Handle request 42” again with backoff, with a new Idempotency-Key per 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 failed with error.code: "stale_reaped" and is not billed.
ResponseRetry?
GET (poll, list, download) answered 502, 503, 504 or a network errorYes, 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 timeoutYes, 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-AfterNever 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_CREDITSNo. Top up the account first.
Other 4xxNo. 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 space and text returns 202 with the original turn’s turn_id and its current state (no duplicate, no second charge).
  • Re-sending the same key with a different space or text returns 409 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):

KeyCapabilityNotes
webWeb and browser research
imageImage generationbillable
mediaAudio and video generationbillable
transcriptionAudio and video transcription
gdriveGoogle Drive
yandex_diskYandex DiskYandex spaces only
yandex_docsYandex Documents and TablesYandex spaces only
seoSEO toolsbillable
codeCode execution
filesFile creation and delivery
researchDeep research
lizardLizard app deploymentbillable
higgsfieldPremium media (Higgsfield: video, image-to-video, upscale)billable, gated
elevenlabsPremium 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.

The model never sees raw credentials. Store credentials as named secrets (or the single app credential via 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 4xx and 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_result tool: a person can do the same job with the same requests.
The turn’s 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 the end_user_ref breakdown in GET /v1/usage.
  • Out of credits (the owner’s balance is exhausted) → 402 INSUFFICIENT_CREDITS with {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_EXCEEDED with {reason:"daily_cap", spent_today_energy, cap_energy, remaining_energy, resets_at} and a Retry-After header (seconds to reset).
  • Per-key monthly cap (optional, in energy) → 429 QUOTA_EXCEEDED with {reason:"monthly_cap", spent_month_energy, cap_energy, remaining_energy} and Retry-After (seconds to the next calendar month, UTC).
  • Per-end-user cumulative cap (total_limit_energy, default none) → 429 QUOTA_EXCEEDED with {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 no Retry-After: raise the limit to grant more.
  • Windowed usage caps (configured by the owner in the dashboard, per space or end user) → 429 QUOTA_EXCEEDED with {reason:"usage_window", scope, end_user_ref, window, metric}, plus limit_energy, spent_energy and remaining_energy for an energy cap (a turns cap carries amount and spent as turn counts), and a Retry-After header.
  • Caps are checked when a turn is created, against energy already charged; remaining_energy in a refusal is therefore 0.
  • Every error reason is a stable machine string; branch on error.code and error.details.reason rather 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..."}}
Deprecated: the USD fields. Until 2026-10-28 responses still carry the older USD twin of each energy field: 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.

LimitValueWhen exceeded
metadata on a turn8 KB of JSON (opaque, echoed back)400 INVALID_REQUEST
file_ids per turn10400 INVALID_REQUEST
Upload size200 MB per file413 FILE_TOO_LARGE
Voice recording (voice_file_id)100 MB; webm, ogg, m4a/mp4, mp3, wav413 VOICE_TOO_LARGE / 415 UNSUPPORTED_MEDIA_TYPE
result text1 MBtruncated, with result_truncated: true
Webhooks per key10409 LIMIT_EXCEEDED
Webhook secret16 to 256 characters400 INVALID_REQUEST
limit on list endpointsdefault 50, max 200clamped
Per-key daily energydefault 50 energy, up to 10,000 energy429 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.

HTTPcodeMeaning
400INVALID_REQUESTMissing 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.
400INVALID_KIND / UNKNOWN_MODELA policy write named an unknown kind, or a model id that is not enabled.
400UNSUPPORTED_MODELPOST /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 / 422RESERVED_SECRET_NAMEA named secret used a reserved name (see Named secrets).
401INVALID_KEYMissing, malformed, unknown or revoked key (also when a space-bound key’s owner no longer owns the space).
402INSUFFICIENT_CREDITSThe account paying for the space is out of credits. Top up to continue; not a rate limit. details.reason = "owner_balance_exhausted".
403KEY_NOT_BOUND_TO_SPACEOn 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).
403FORBIDDENThis 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}).
403CALLS_NOT_ENABLEDPOST /v1/spaces/{slug}/calls: voice calls are not part of the space owner’s plan (Plus).
503CALLS_UNAVAILABLEPOST /v1/spaces/{slug}/calls: the voice room cannot take a call right now (switched off, or at capacity); try again in a minute.
403COMMANDER_IS_PERSONALThe target is a personal Commander space, which is never reachable over the API.
403OPERATOR_ONLYA policy write named a kind a space cannot set; details.who says who can.
403INELIGIBLEA policy write named a model this space may not use (admin-only, or outside its allowed models).
400INVALID_CURSORA cursor or after that is not one this API returned (or has expired).
404SPACE_NOT_FOUND / APP_API_NOT_FOUND / TURN_NOT_FOUND / TASK_NOT_FOUND / FILE_NOT_FOUND / SECRET_NOT_FOUND / WEBHOOK_NOT_FOUND / DELIVERY_NOT_FOUNDNot found, or not visible to this key (existence isn’t leaked).
409IDEMPOTENCY_CONFLICTIdempotency-Key reused with a different space or text.
409CONFLICTAn app API name is already used by an MCP server registered through /tools.
409ALREADY_TERMINALCancel on an already finished turn.
409LIMIT_EXCEEDEDThe key already has 10 webhooks.
409WEBHOOK_INACTIVERedeliver on a disabled webhook; re-activate it first.
413FILE_TOO_LARGEUploaded file over the 200 MB limit.
413VOICE_TOO_LARGEThe voice_file_id recording is over 100 MB (see Voice input).
415UNSUPPORTED_MEDIA_TYPEThe voice_file_id upload is not audio in a supported format.
429QUOTA_EXCEEDEDDaily 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_result tool. 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 is done.
  • No Idempotency-Key. A retry then double-charges. Always send one per logical message.
  • Retrying a cap 429 at once. Wait the Retry-After seconds; an end-user cap needs a higher limit, not a retry.
  • Making your own caller wait for a turn. Answer 202 at 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 *_usd fields. They go away on 2026-10-28; read energy_used and the other *_energy fields.
  • 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_ref in the create body. It isn’t stored today; keep your own your_user_id → space_id map 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 as APP_OPENAI_API_KEY.
  • Showing a failed turn as the request’s error. Read error.code; for SUBAGENT_BLOCKED or SUBAGENT_PARTIAL send 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-Signature against the raw body, not re-serialized JSON.
  • Doing the work before answering the webhook. Answer 2xx first, 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.