Octomatica API
Embed an Octo AI agent inside your own app: create isolated, multi-user spaces, give each one your tools and a durable manifest, send prompts, and read replies — billed per end-user.
Octomatica is a general agent-space substrate. A space is one isolated agent:
Space = Agent core + Channel + Toolset + Manifest + Engine/Model
Telegram is one channel; this HTTP API is another. Your backend creates spaces, configures them, sends turns (prompts), and reads replies. You pay Octo in aggregate and re-bill your own users using the per-end-user breakdown.
Base URL & auth
| Item | Value |
|---|---|
| Base URL | https://dash.octomatica.ru/v1 |
| Auth header | Authorization: Bearer octo_live_… on every request |
| Interactive docs | /api/docs (Swagger) · /api/openapi.json |
| Content type | application/json (except file download, which streams bytes) |
| Errors | { "error": { code, message, details, trace_id } } + an X-Trace-Id response header |
A missing or invalid key returns 401 INVALID_KEY. The plaintext key is shown once at creation — store it.
Get a key — and scope it
Create keys in the dashboard → Integrations → API Keys → Create key, then choose Space access:
| Choice | What the key can control |
|---|---|
| Only spaces this key creates (recommended) | ONLY spaces it provisions via POST /v1/spaces. It can never touch your existing (Telegram / other) spaces. |
| One specific space | A single space slug you name. |
| All my spaces | Every space your account owns (account-wide, legacy). |
The golden path
1. POST /v1/spaces → { space_id } # create an isolated space; store the slug
2. PUT /v1/spaces/{slug}/manifest # how the agent behaves / drives your app (durable)
PUT /v1/spaces/{slug}/secret # your app credential (encrypted, write-only)
PUT /v1/spaces/{slug}/tools # allowlist built-ins + register your MCP server
3. POST /v1/turns {space, text, end_user_ref} → 202 { turn_id, state:"queued" }
4. GET /v1/turns/{turn_id} # poll until state is done | failed | cancelled
5. GET /v1/turns/{turn_id}/files # any files it produced → GET /v1/files/{file_id}
GET /v1/usage?group_by=end_user # per-end-user spend, to re-bill your users
Step 2 configures once (or inherits later); steps 3–5 repeat per message. end_user_ref is your own opaque customer id (like OpenAI’s user param) — it drives per-user billing and caps; it is not an Octo account.
Endpoints · Spaces
| Method & path | Purpose |
|---|---|
POST /v1/spaces → 201 | Create an isolated API space. Body {engine?, model?, title?} — engine + model per Engines & models. Returns { "space_id": "<slug>", "origin": "api" }. |
GET /v1/spaces | List your API spaces (an own-scoped key lists only the ones it created). |
GET /v1/spaces/{slug}/turns?limit=50 | Recent turns for a space (max 200). |
PUT /v1/spaces/{slug}/manifest | Set the durable manifest. Body { "manifest": "..." }. |
GET /v1/spaces/{slug}/manifest | Read it. |
PUT /v1/spaces/{slug}/secret | Store the space’s app credential (write-only, encrypted, never returned). Body { "secret": "..." }. |
PUT /v1/spaces/{slug}/tools | Set the toolset (see Tools & MCP). |
GET /v1/spaces/{slug}/tools | Read the toolset config. |
GET /v1/tools/catalog | The built-in capability keys you may allowlist. |
GET /v1/models | The engine + model options for POST /v1/spaces (see Engines & models). |
GET /v1/spaces/{id} and DELETE/archive. Track slugs yourself — store the returned space_id against your own record. (external_ref in the create body isn’t persisted today.)Engines & models
A space runs on one engine + model, chosen at POST /v1/spaces and honored on every turn. Send these exact values (also served by GET /v1/models):
| Engine | Model | Send |
|---|---|---|
| Claude Code (Anthropic) | Opus | {"engine":"claude-code","model":"opus"} |
| Opencode | DeepSeek V4 Pro | {"engine":"opencode","model":"deepseek/deepseek-v4-pro"} |
| Opencode | Codex (GPT-5.5) | {"engine":"opencode","model":"gpt-5.5"} |
engine defaults to claude-code. An unknown engine, or a model that isn’t valid for that engine, is rejected 400 INVALID_REQUEST.
Endpoints · Turns
| Method & path | Purpose |
|---|---|
POST /v1/turns → 202 | Send a prompt. Body {space, text, end_user_ref?, metadata?}. Header Idempotency-Key. Returns {turn_id, state:"queued", trace_id} + a Location header. |
GET /v1/turns/{turn_id} | Poll status / result (see lifecycle). |
POST /v1/turns/{turn_id}/cancel | Cooperatively cancel a non-terminal turn (else 409 ALREADY_TERMINAL). |
GET /v1/turns/{turn_id}/files | List files the turn produced: [{file_id, name, content_type, size, url}]. |
GET /v1/files/{file_id} | Download a produced file (streamed bytes; Content-Disposition filename). |
Endpoints · Usage & per-end-user caps
| Method & path | Purpose |
|---|---|
GET /v1/usage?group_by=…&scope=key|account | Spend + turn-count rollup. scope=key = only this key’s turns; default scope=account = the whole account (for re-billing across your keys). |
GET /v1/spaces/{slug}/end-users | End-users with cumulative spend + limit. |
PATCH /v1/spaces/{slug}/end-users/{ref} | Set an end-user’s cumulative cap. Body { "total_limit_usd": 5.0 } (null = uncapped). |
Turn lifecycle
queued → running → done (result ready)
→ failed (error set)
queued →→→→→ 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": ..., "output_tokens": ..., "total_tokens": ...,
"provider": "...", "model": "..." },
"billed_cost_usd": 0.0123,
"metadata": { ...you echoed it back... },
"created_at": "...", "started_at": "...", "completed_at": "..."
}
Turns are async — poll every ~1–2 s with backoff; expect seconds, not milliseconds.
Idempotency
Send a unique Idempotency-Key header on POST /v1/turns (a UUID per logical message):
- Re-sending the same key + same body returns the original turn (no duplicate).
- Re-sending the same key with a different body →
409 IDEMPOTENCY_CONFLICT. - Without it, a network-blip retry creates a second paid turn. Always send it.
Tools, MCP & results
PUT /v1/spaces/{slug}/tools body:
{
"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": { "...": "..." } }
]
}
Built-in capability keys (from GET /v1/tools/catalog): web, image, media, transcription, gdrive, seo, code, files, research. Least-privilege: allow only what the space needs.
PUT /v1/spaces/{slug}/secret; it’s 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 once, not re-sent per turn. Tools work on both engines (claude-code + opencode).Getting results back — call your tools, don’t parse the reply
This is the robust pattern, and the one to build. Define the output you want as one of your tools — an MCP tool or a REST action with a typed input schema — and in the manifest tell the agent to deliver its result by calling that tool (e.g. submit_report(title, sections, findings, …)). Then:
- The structured data arrives through your endpoint, already typed and validated by you.
- If a call doesn’t match your schema, return a clear validation error — the agent reads it and retries with corrected arguments (both engines self-heal on tool errors). Your input schema is the contract; the agent converges to it.
- So you never write a JSON-from-text parser, a schema validator, or a retry loop over the reply — the tool layer gives you all three for free.
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 machine-readable output is whatever the agent sent to your tools during the turn (built-in files deliver artifacts the same way — fetch via GET /v1/turns/{id}/files).Billing
- Every turn bills your account (the key’s owner) at
billed_cost_usd. Octo bills you in aggregate; you re-bill your own users from theend_user_refbreakdown inGET /v1/usage. - Per-key daily cap (default $5/day, resets at UTC midnight) →
429 QUOTA_EXCEEDEDwith{spent_today_usd, cap_usd, resets_at}. - Per-end-user cumulative cap (
total_limit_usd, default none) →429 QUOTA_EXCEEDEDonce that end-user’s lifetime spend hits it. Raise it to “grant more credits.” - Only spend is throttled — there is no requests-per-minute limit.
Error codes
| HTTP | code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | Missing space/text, bad group_by, unknown tool key, metadata > 8 KB. |
| 401 | INVALID_KEY | Missing / malformed / unknown / revoked key. |
| 403 | KEY_NOT_BOUND_TO_SPACE | The key may not control that space (wrong binding, not owned, or — for a key scoped to its own spaces — it didn’t create that space). |
| 404 | SPACE_NOT_FOUND / TURN_NOT_FOUND / FILE_NOT_FOUND | Not found, or not visible to this key (existence isn’t leaked). |
| 409 | IDEMPOTENCY_CONFLICT | Idempotency-Key reused with a different body. |
| 409 | ALREADY_TERMINAL | Cancel/complete on an already-finished turn. |
| 413 | FILE_TOO_LARGE | Uploaded file over the 25 MB limit. |
| 429 | QUOTA_EXCEEDED | Daily key cap or per-end-user cap reached (details says which). |
Payload limits: metadata ≤ 8 KB (opaque, echoed back) · result ≤ 1 MB (truncated, with result_truncated:true) · file ≤ 25 MB.
Worked example
BASE=https://dash.octomatica.ru/v1
KEY=octo_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 1) create an isolated space (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 '{"engine":"claude-code","title":"Acme concierge for user 42"}' | jq -r .space_id)
# 2) give it a manifest + your app credential + your tools
curl -s -X PUT $BASE/spaces/$SLUG/manifest -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"manifest":"You are Acme'"'"'s in-app assistant. Use the acme.* tools to read orders..."}'
curl -s -X PUT $BASE/spaces/$SLUG/secret -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"secret":"acme_app_key_live_..."}'
curl -s -X PUT $BASE/spaces/$SLUG/tools -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"allow":["web","files"],"mcp_servers":[{"name":"acme","url":"https://mcp.acme.com"}]}'
# 3) run a turn for one of your end-users (idempotent)
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":"Where is my order #1001?"}' | jq -r .turn_id)
# 4) poll until terminal
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
Client poll loop (pseudocode):
turn = POST /v1/turns { space, text, end_user_ref, headers:{ "Idempotency-Key": uuid() } }
loop:
t = GET /v1/turns/{turn.turn_id}
if t.state in {done, failed, cancelled}: break
sleep(1.5s, capped backoff)
if t.state == "done": deliver t.result (+ GET /v1/turns/{id}/files if any)
elif t.state == "failed": show t.error.message
Common mistakes
- Parsing the turn
resultas JSON. It’s a free-text human summary. Deliver structured data through your own MCP tool / REST action — the agent calls it and retries to match your schema (see Tools, MCP & results). Don’t build a parser + validator + retry over the reply. - Waiting on
succeeded. The terminal success state isdone. - No
Idempotency-Key. A retry then double-charges. Always send one per logical message. - 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 persisted today — keep your ownyour_user_id → space_idmap from the returned slug. - Putting secrets in the prompt. Store them via
PUT …/secret; the model only ever sees tool results, never the credential. - Not handling
failed. Always branch on the terminal state; readerror.code/message.