One API
Errors, limits & headers
The platform has two error envelopes. The execution planes (tools, agents, workflows, MCP, discovery, traces) use the router envelope; the models plane (/v1/chat/completions, /v1/embeddings, /v1/models) uses the OpenAI-shaped envelope from the inference gateway. Branch on the HTTP status and, on the execution planes, the X-Error-Code header — not on any field's type.
13.1 The two envelopes
Execution planes (orchestration/api-gateway/lib/respond.ts, lib/format.ts):
{ "error": { "code": "unknown_agent", "message": "No such agent: foo", "…": "extra fields" } }- Header
X-Error-Code: <code>is always set — clients can branch on it without parsing the body. - Header
x-request-idechoes the server-minted id. - The body is format-negotiated:
?format=/body.format/Accept/ atext/yamlcontent-type mirror selectjson(default),yaml,markdown, ortext. An unknown explicit format →400 invalid_format(as JSON).code+messagesurvive every format;missing,contract,hint/detail,outcomeand the execution id render onmarkdown(the MCP renderer) andmissingontext; every field survivesjson/yaml.
Models plane (inference/gateway/src/errors.ts) — OpenAI shape, no X-Error-Code:
{ "error": { "message": "Invalid API key", "type": "authentication_error", "code": "invalid_api_key", "request_id": "…" } }codeis a string,typegroups it,request_idis inside the body. Response headers carryx-request-id(andx-correlation-idwhen you sent anx-request-id), neverX-Error-Code.- A known path called with the wrong method (
GET /v1/chat/completions,POST /v1/models) answers405 method_not_allowedwith anAllowheader naming the one method it serves:
| HTTP | code | When | Do |
|---|---|---|---|
| 405 | method_not_allowed | A models-plane path called with the wrong method | Use the method in Allow |
13.2 Execution-plane error codes
| HTTP | code | When | Do |
|---|---|---|---|
| 400 | invalid_json / invalid_body / invalid_request | Body is not valid JSON (the message quotes the parser's position) / its root is not an object or mapping | Fix the body |
| 400 | invalid_format | ?format=/body.format is not json|yaml|markdown|text | Use a known format |
| 400 | missing_prompt | Agent execute without a prompt/input string and without a messages user turn | Send a prompt or messages |
| 400 | missing_workflow | Workflow execute with none of workflow, agent or tasks | Send a workflow: document or a bare agent: body (10.2) |
| 400 | ambiguous_body | agent: next to workflow:/tasks: in one body | Send one of them |
| 400 | reserved_field | A bare-agent field named like an engine task key (id, type, agent, depends_on, mission_ref); the body names them | Rename the field |
| 400 / 422 | invalid_workflow | Engine rejected the workflow (structural) / a save route refused the YAML: schema or kind lint, with errors and warnings and nothing written (16.1) | Fix the CNL |
| 400 | invalid_yaml | A text/yaml body is not valid YAML (every execute door) | Fix the YAML syntax (the message quotes js-yaml's reason and position) |
| 401 | unauthorized | Missing/invalid/revoked ozk_ key | Send a valid key (Auth) |
| 401 | invalid_signature | Internal HMAC identity failed | Service-to-service only |
| 403 | refused | Trust fence — agent/tool refused (13.5) | Do not retry |
| 404 | not_found | No route for method+path | Check the path |
| 403 | agent_not_active | The agent's card is not status:active (refused before dispatch) | Pick an active agent (Discovery) |
| 403 | lock_admin_required | PUT /v1/workflows/{id}/permissions set locks and the caller is not a team admin over the workflow; nothing was written (16.2) | Ask an admin |
| 404 | unknown_agent | No such agent id on the agents, tools and workflows planes, and for a task inside a workflow body | Discover a valid id (Discovery) |
| 404 | agent_not_found | POST /v1/execute (16.9): agent: names no saved agent of yours (or snapshot) and no building block | Check the id, and the @n snapshot |
| 404 | unknown_tool | No such tool id | Discover a valid id |
| 404 | workflow_not_found | No saved workflow with that id for this key (a foreign id answers the same) | Check the id/owner |
| 404 | execution_not_found, schedule_not_found, trigger_not_found, group_not_found, binding_not_found, file_not_found, version_not_found, template_not_found, skill_not_found, memory_not_found, connection_not_found, unknown_provider | The Bots routes (16) and connections (7): no such row for this key. Another account's id answers the same | Check the id |
| 404 | run_not_found | MCP run never opened, closed, or expired (30 min idle); on GET /v1/memory/runs/{id}, no past run with that id | Open a new run with POST /v1/mcp/runs; retrying never revives it |
| 404 | not_ready | Result not stored yet (still running) | Poll again |
| 409 | approval_required | Trust fence — human approval pending (13.5) | Approve, then retry |
| 409 | execution_failed | Result route: the run failed | Read the message |
| 409 | duplicate_execution_id | Replayed x-trace-id UUID hit the run PK | Send a fresh UUID or omit it |
| 409 | not_paused, execution_finished | POST /v1/executions/{id}/continue on a run that already completed; steer or stream on a run that is no longer running (16.3) | Use followup, or read the output |
| 409 | memory_conflict, skill_name_taken, name_taken, binding_limit | A memory id that is also inside another entry; a skill name held by a platform skill or another account; a bot-group name you already use; the account's binding limit (16.6, 16.7) | Forget by text; pick another name; unbind one |
| 410 | session_expired, result_expired | No conversation session is left for continue; the stored result of output is gone (16.3) | Start a new run |
| 413 | payload_too_large | Body over the size cap (13.6) | Shrink the body |
| 422 | invalid_params | Outcome needs_input — agent needs different args | Fix the params |
| 422 | unknown_model | Pinned model absent/inactive in the catalog | Discover a model |
| 422 | not_paired, memory_rejected, invalid_name, invalid_members | A chat you did not pair; a memory write the store refuses (it carries current_entries); a bot-group name or member list that fails its checks (16.6, 16.7) | Fix the request |
| 422 | empty_completion | Run completed but produced no visible content | Raise max_tokens (≥64) |
| 424 | connection_required | Outcome needs_connection — no connected account | Connect the account (Connectors) |
| 424 | credential_required | Outcome needs_api_key — no API key for a connector | Add the key |
| 429 | rate_limited | Tier RPM/RPD or failed-auth cap (13.4) | Honour Retry-After |
| 500 | config_error | Server misconfiguration | Report with x-request-id |
| 500 | internal_error | Unhandled router error | Report with x-request-id |
| 502 | engine_error | Engine failed (untagged) | Retry; report if persistent |
| 502 | upstream_error, storage_error, scheduler_error, registry_unavailable | Upstream service/storage error; on the Bots routes the storage, scheduler or skill registry failed and no detail is returned | Retry with backoff |
| 502 | tool_call_failed | Tool execution failed | Read the message |
| 502 | no_servers_connected | MCP: no server connected | Connect a server |
| 502 | result_unavailable | Run finished but the result could not be retrieved | Retry the result route |
| 503 | not_enabled, unavailable, connections_unavailable | A feature is not switched on in this deployment yet: channel bindings, bot groups, outbound hooks, connections (16) | Retry later |
| 503 | agent_unavailable | Outcome unavailable — agent temporarily down | Retry later |
| 503 | provider_unconfigured | Chosen provider has no key on this platform | Choose another provider |
| 503 | money_plane_unavailable | Model routes not staged on this tier | Retry later |
13.3 Outcome → HTTP mapping
Execute routes classify an agent/tool outcome (routes/_util.ts outcomeStatus). A success: false engine result tagged with an outcome remaps to a caller-actionable status; an untagged failure stays 502 engine_error.
| Outcome | HTTP | code |
|---|---|---|
needs_input | 422 | invalid_params |
needs_connection | 424 | connection_required |
needs_api_key | 424 | credential_required |
unavailable | 503 | agent_unavailable |
error (or untagged) | 502 | execution_failed / engine_error |
needs_approval | 409 | approval_required (13.5) |
13.4 Rate limits
Execution planes (orchestration/mcp-gateway/rate-limiter.ts, a Durable Object; enforced in lib/auth.ts). Counted on POST executions only — catalog GETs are free. Per tier:
| Tier | per minute | per day |
|---|---|---|
free | 30 | 1,000 |
pro | 100 | 10,000 |
enterprise | 300 | 100,000 |
A key with no tier is treated as free. A per-key rate_limit override is ignored on these planes (lib/auth.ts does not pass it to checkRateLimit); it applies only on https://mcp.plungeai.com/v1. Failed authentications are metered per client IP (auth-fail: 20/min, 500/day) to bound key brute-force. On a limit hit: 429 rate_limited with a Retry-After header (seconds). A key is never shut out for longer than ten minutes: reaching the day limit pauses the key for 10 minutes (Retry-After ≤ 600), after which its day count starts over (2026-10-05; the minute limit waits out the minute as before). The per-tier numbers are the defaults: the operator can change them or switch the limits off (the failed-authentication meter stays on), see 2.
Money plane (inference/gateway/src/auth/rate-limit.ts). Per key, atomic per-minute window (RateLimiterDO, KV soft-cap fallback): default 600 req/min (RATE_LIMIT_RPM_DEFAULT) → 429 rate_limit_exceeded. Separate from billing caps: 429 insufficient_quota (monthly limit / trial allowance), 402 insufficient_balance (empty wallet), 429 spend_cap_exceeded (guardrail) — see 3.10, 3.13.
13.5 Trust fence
Two statuses gate risky actions:
403 refused— the agent/tool refused the action outright (policy). Do not retry unchanged.409 approval_required— a human must approve before the action runs. Approve out-of-band, then re-issue the request.
13.6 Limits: body caps, timeouts
- Body cap (execution planes):
MAX_REQUEST_SIZE, default 1 MiB (routes/_util.ts—1_048_576bytes). Enforced againstContent-Lengthfirst, then streamed; over →413 payload_too_large. The models plane relies on the provider/runtime body limits. - Timeouts: there is no gateway-imposed request timeout beyond the Cloudflare Workers runtime and each upstream's own. Streams have no idle timeout on the money/workflows plane; the agents plane sends
: OCEAN PROCESSINGkeepalives (4.3). - Storage errors are scrubbed: a D1/SQLite failure surfaces as
502 upstream_error"storage error" (the raw error is logged, never leaked); internal service-binding names are redacted from messages.
13.7 Retry guidance
- 429 — back off and honour
Retry-After. Distinguishrate_limited/rate_limit_exceeded(throttle) frominsufficient_quota/spend_cap_exceeded(billing — retrying won't help until the window resets or billing changes). - 5xx (
502/503) — retry with exponential backoff; the models plane already retries429/5xxonce per candidate and fails over (3.3). - 4xx other than 429 — do not retry unchanged; fix the request.
needs_*outcomes (422/424) are yours to resolve (params, connection, key). - Always include the
x-request-idwhen reporting an error.
13.8 Response headers
| Header | Plane | Meaning |
|---|---|---|
x-request-id | both | Server-minted request id: the router's on the execution planes, the inference gateway's (= error.request_id) on the models plane. A client-sent x-request-id is never adopted — it comes back as x-correlation-id. |
x-ocean-router-id | models | The router's own id for a models-plane call (the id x-request-id carried before the two were split). |
X-Error-Code | execution (errors) | The error code, for header-only branching. |
X-Execution-Id | execution | The run id on results, streams, and async pointers. |
x-correlation-id | both | Echo of your x-request-id when you send one. A successful models-plane call without one carries the router's trace id here. |
x-generation-id | models (/api/v1) | The gen-… completion id (= the body id), the id GET /generation?id= takes (3.6). |
x-ocean-dropped-params | models | Request fields the routed provider shape could not take, comma-separated (3.2). |
x-cache | models | hit / miss on cache-eligible chat requests (3.11). |
x-ocean-cost-usd | models | The settled price in USD (6 decimals) of a non-stream POST /v1/chat/completions; 0.000000 on a response-cache hit. Absent on streams (use usage.cost) and on the execution planes (priced later). |
x-ocean-agent · x-ocean-tasks · x-ocean-version | execution (POST /v1/execute) | What ran: the saved agent or building block (absent for an inline workflow), how many top-level tasks, and the snapshot (<n>, live or inline) (16.9). |
Server-Timing | execution | total;dur=<ms> timing. |
X-Content-Type-Options | execution | nosniff on every router response. |
Retry-After | both | Seconds to wait after a 429. |
Access-Control-Allow-Origin (+ CORS) | both | *; both planes answer OPTIONS preflight and advertise the same methods (GET, POST, PUT, PATCH, DELETE, OPTIONS). |
Access-Control-Expose-Headers | both | The headers a browser client may read: x-request-id, x-ocean-router-id, x-execution-id, X-Error-Code, Retry-After, Server-Timing, x-cache, x-ocean-cost-usd, x-generation-id, x-correlation-id, x-ocean-dropped-params, x-ocean-agent, x-ocean-tasks, x-ocean-version, Content-Disposition. |
Allow | models | On a 405: the one method the path serves, plus OPTIONS. |
Content-Type | both | application/json | text/event-stream | negotiated text/yaml|text/markdown|text/plain. |
13.9 Verified examples
Unauthenticated execution-plane request → 401 unauthorized with the x-error-code header:
curl -is "$API_BASE/v1/agents"Unknown route → 404 not_found, header x-error-code: not_found, plus x-request-id:
curl -is "$API_BASE/v1/nope"Agent execute without a prompt → 400 missing_prompt:
curl -s "$API_BASE/v1/agents/one-agent/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" \
-H "Content-Type: application/json" -d '{}'Unknown agent id → 404 unknown_agent (the trust/fence pre-dispatch check):
curl -s "$API_BASE/v1/agents/zzz-not-an-agent/execute" \
-H "Authorization: Bearer $PLUNGE_API_KEY" \
-H "Content-Type: application/json" -d '{"prompt":"hi"}'