Contents

API Reference

The API is JSON over HTTPS at https://api.parel.sh; a deploy may also send its raw YAML. Every request carries a workspace API key in the Authorization header:

▓ auth
Authorization: Bearer pk_your_api_key

In the paths below, :name is an agent's name and :idOrName takes its id or its name. Errors come back as JSON with a message and, for most of them, a stable code (see Errors).

# API key scopes

Every API key has one scope. New keys get Write unless you pick another.

ScopeAllows
readGET requests only: it can read everything and change nothing. It can't open a session WebSocket.
writeEverything except changing API keys, model provider keys, secrets and billing: deploy agents, start sessions, send messages. A deploy may still carry secrets for its own agent.
adminEverything, including writes to /api-keys, /provider-keys, /secrets and /billing.

# Agents

An agent is addressed by its name, which is unique in the workspace. Each deploy uploads an immutable version; a deployment decides which version is live.

POST
/agents/:name/versions
Deploy: upload a config as a new version and make it live. The first deploy creates the agent (201, then 200). ?activate=false uploads it without making it live

Send the raw agent.yaml, or JSON with the config text and optional extras. The name in the path wins over agent.name in the config. secrets are saved for this agent before the config is checked; an upload that doesn't go live can't carry them. pluginArtifacts point at packages uploaded with POST /plugin-artifacts. The parel deploy CLI does all of this for you.

▓ deploy
# agent.yaml as is; add ?activate=false to upload without going live
curl -X POST https://api.parel.sh/agents/my-agent/versions \
  -H "Authorization: Bearer $PAREL_API_KEY" \
  -H "Content-Type: text/yaml" \
  --data-binary @agent.yaml

# or JSON
{
  "config": "<agent.yaml text>",
  "activate": true,
  "secrets": { "E2B_API_KEY": "e2b_..." },
  "pluginArtifacts": []
}

→ 201 { "id": "agt_...", "name": "my-agent", "version": 1,
        "versionId": "ver_...", "active": true }
POST
/agents/validate
Check a deploy without doing it: same body as a deploy, writes nothing, always answers 200 with a verdict

Problems are the answer, not an HTTP error: ok is false when errors is not empty, and warnings never block a deploy. Only a broken request (bad JSON, no config) gets a 400.

▓ validate
POST /agents/validate        (same body as a deploy)
→ 200
{
  "ok": false,
  "errors": [{ "code": "secret_unresolved", "message": "..." }],
  "warnings": [
    { "code": "agent_name_exists", "message": "...", "path": "agent.name" }
  ]
}
GET
/agents
List agents with their model, session counts and total cost
GET
/agents/:idOrName
Get one agent with its live config and default vars (vars_json)
PATCH
/agents/:idOrName
Replace the agent's default vars (body: vars). Each instance can override them per name; running sessions pick up the change at their next turn
DELETE
/agents/:idOrName
Delete an agent with its versions, agent secrets, channel bindings and instances. Its sessions stay readable
POST
/agents/:idOrName/rename
Rename an agent (body: name). Its id, versions and sessions stay. 409 if the name is taken
POST
/plugin-artifacts
Upload a packed plugin (.tgz, up to 25 MB) so a deploy can use a local plugin. Query: name, version, integrity. Returns the artifactKey to list in pluginArtifacts
POST
/agents
Legacy: deploy with the name taken from the config. Still works and answers with a Deprecation header; use POST /agents/:name/versions
PUT
/agents/:id
Legacy: add a version by agent id. Still works and answers with a Deprecation header; use POST /agents/:name/versions

# Versions and deployments

Versions are numbered v1, v2 and so on. Wherever a version is asked for, you can give the number (3), the text "v3", or the version id. Making a version live also sets up the channels its config declares.

GET
/agents/:idOrName/versions
List versions, newest first; active marks the live one
GET
/agents/:idOrName/versions/:version
Get one version with its config
POST
/agents/:name/deployments
Make a version live, newer or older (body: version). With dryRun: true, or ?dryRun=true, nothing changes and the answer checks that version against every instance's vars
GET
/agents/:idOrName/deployments
List when each version went live (kind: deploy or rollback), newest first
POST
/agents/:idOrName/rollback
Roll back (body: to, optional). Without to, it goes back to the version that was live before the current one
▓ deployments
POST /agents/my-agent/deployments
{ "version": "v3" }
→ { "id": "agt_...", "name": "my-agent", "version": 3, "active": true }

{ "version": "v4", "dryRun": true }
→ { "id": "agt_...", "name": "my-agent", "version": 4, "dryRun": true,
    "active": false, "ok": true,
    "instances": [{ "key": "main", "tracking": "live", "affected": true,
                    "ok": true, "unresolved": [], "errors": [], "warnings": [] }] }

# Instances

An instance is one long-lived copy of an agent with its own state (sandbox, instance store), its own vars, and either the live version or a pinned one. Every session runs on an instance. main always exists; any other key is created the first time it is used. Keys are lowercase letters, digits and ._-, up to 64 characters.

GET
/agents/:idOrName/instances
List instances, main included, with session counts and cost
GET
/agents/:idOrName/instances/:key
Get one instance, with its vars_json and generation
PATCH
/agents/:idOrName/instances/:key
Create or update one instance: pin it to a version or let it follow the live one, and/or replace its vars
PATCH
/agents/:idOrName/instances
The same update for up to 500 instances in one call. Each item is applied or refused on its own, results come back in order, and sending the same body again is safe
POST
/agents/:idOrName/instances/:key/reset
Wipe the instance's state (sandbox, instance store) and add one to its generation. Sessions and their history stay. Repeating a reset with the same generation string returns the first result
DELETE
/agents/:idOrName/instances/:key
Delete an instance and its state. 409 while it has live sessions (?force=true ends them first) or a channel binding points at it. main can only be reset
▓ instances
PATCH /agents/my-agent/instances/acme
{ "tracking": "pinned", "version": "v3" }
{ "tracking": "live", "vars": { "CUSTOMER": "Acme" } }

PATCH /agents/my-agent/instances
[
  { "key": "acme", "vars": { "CUSTOMER": "Acme" } },
  { "key": "globex", "tracking": "pinned", "version": 3 }
]
→ { "ok": true, "results": [{ "key": "acme", "ok": true, "instance": { ... } }, ...] }

POST /agents/my-agent/instances/acme/reset
{ "generation": "reset-2026-10-05" }
→ { "reset": true, "key": "acme", "generation": "reset-2026-10-05",
    "instanceGeneration": 4 }

# Sessions

A session is one conversation with an agent. It runs on an instance, main unless you name another, and keeps its history until you end it.

POST
/sessions
Start a session

agent takes the agent's id or name. version runs that exact version on a throwaway instance and can't be combined with instance; ephemeral: true asks for a throwaway instance on the live version. With requireExistingInstance: true a missing instance is a 404 instance_not_found instead of being created. While 25 of the workspace's sessions are running, starting another returns 429.

GET
/sessions
List sessions, most recently updated first. Query: agent_id (the id, not the name), status, limit (up to 200, default 50), offset
GET
/sessions/:id
Get a session's state, including lastActivityAt and what it is doing right now
POST
/sessions/:id/fork
Copy the session's history and state into a new, independent session. Body: input (optional; run as its first message). An Idempotency-Key header, or idempotencyKey in the body, returns the same copy on a retry
POST
/sessions/:id/terminate
End a session: stops a running turn and refuses further messages. Body: reason (optional). Safe to repeat
GET
/sessions/:id/commands
List the slash commands this session accepts: the built-in /help plus any that plugins add
POST
/agents/:idOrName/sessions
Legacy: start a session with the agent in the path. Still works; use POST /sessions
▓ sessions
POST /sessions
{ "agent": "my-agent", "instance": "acme" }
→ 201 { "id": "ssn_...", "agentId": "agt_...", "instance": "acme", "status": "ready" }

{ "agent": "my-agent", "version": "v4" }
→ 201 { "id": "ssn_...", "agentId": "agt_...", "ephemeral": true,
        "version": "ver_...", "status": "ready" }

POST /sessions/ssn_.../fork
Idempotency-Key: fork-ticket-42
{ "input": "Now try the cheaper option" }
→ 201 { "id": "ssn_...", "status": "ready", "turnId": "trn_..." }

# Messages

Sending a message starts a turn, or queues the message while another turn runs. The call returns right away; follow the turn over the WebSocket, through the events, or with GET /turns/:turnId.

POST
/sessions/:id/messages
Send a message

content is a string or a list of parts. Media goes inline as base64: image parts for PNG, JPEG, GIF and WebP, a file part for PDF, and the declared mediaType must match the bytes. Per message: at most 16 parts and 3 media items, 1 MiB per item and 1.25 MiB of media in total. A text message can be up to 1 MiB.

Send an Idempotency-Key header (up to 200 characters) so a retry never runs twice: the same key answers with the original input and turn. injectInFlight: true hands the message to the running turn at its next step; with no turn running it starts one as usual. context is an optional object passed to plugins that ask for it; it doesn't enter the history. expectedInstanceGeneration makes the call fail with 409 if the instance was reset in the meantime.

▓ messages
POST /sessions/ssn_.../messages
Idempotency-Key: ticket-42-msg-1

{ "content": "Summarize yesterday's tickets" }

{
  "content": [
    { "type": "text", "text": "What does this chart show?" },
    { "type": "image", "data": "<base64>", "mediaType": "image/png" },
    { "type": "file", "data": "<base64>", "mediaType": "application/pdf",
      "filename": "report.pdf" }
  ],
  "injectInFlight": true
}

→ 200 { "status": "accepted", "inputId": "inp_user_ticket-42-msg-1",
        "turnId": "trn_..." }
→ 200 { "status": "queued", "inputId": "inp_user_ticket-42-msg-1" }

status is accepted (a turn started, see turnId), queued (it waits for the running turn) or executed (a slash command ran without a turn, see result). warnings lists things worth knowing, for example that the model can't see images.

Refusals: 400 media_invalid, 413 when the message is too large, 429 when too many inputs are already waiting, and 409 when the session can't take a turn: session_terminated, billing_insufficient, billing_suspended, budget_exceeded, turn_limit or instance_generation_mismatch.

GET
/sessions/:id/messages
Read the history; ?view=chat returns only what a chat window shows
POST
/sessions/:id/inputs
Deliver an input that isn't a user message: channel_event or async_callback (wakes the session), steer or interrupt (for the running turn). Body: type, payload, idempotencyKey (or the Idempotency-Key header)
POST
/sessions/:id/steer
Legacy: a message for the running turn. Same as POST /sessions/:id/messages with injectInFlight: true

# Turns

A turn is one round trip, from an input to the agent's final reply. Its outcome is kept for good, so you can look it up long after the events have moved on.

GET
/turns/:turnId
Get the outcome of a turn
GET
/sessions/:id/turns
Find the turn that handled the message sent with this Idempotency-Key (query: by_idempotency_key). 404 means this session never received it
▓ turns
GET /turns/trn_...
→ {
  "id": "trn_...", "sessionId": "ssn_...", "agentId": "agt_...",
  "instanceKey": "main", "instanceGeneration": 0, "turnNumber": 3,
  "status": "error", "completionKind": "retryable",
  "errorCode": "rate_limited", "errorMessage": "...", "retryAfterMs": 30000,
  "createdAt": "...", "completedAt": "...", "terminal": true
}

GET /sessions/ssn_.../turns?by_idempotency_key=ticket-42-msg-1
→ { "inputId": "inp_user_ticket-42-msg-1", "status": "completed", ... }

queued, running and paused mean the turn is still in progress; completed, error and cancelled are final, and terminal tells you which group you have. completionKind says how a turn ended: success, failed, retryable (with retryAfterMs) or cancelled. A queued message has no turn fields yet.

# Events and logs

Each session keeps a numbered list of events. Read it in pages: pass the last nextSeq you got as since_seq, and keep going while hasMore is true. If your since_seq is older than earliestAvailableSeq, the events in between are no longer kept.

GET
/sessions/:id/events
A session's events in order. Query: since_seq, limit (up to 5000, default 500)
GET
/sessions/:id/steps
What each step did: model_reasoning, tool_call, tool_result and turn_failed records. Query: since_seq, limit (up to 1000, default 200); same paging as events
▓ events
GET /sessions/ssn_.../events?since_seq=120&limit=500
→ {
  "events": [
    { "id": "evt_...", "seq": 121, "type": "turn:start",
      "data": "{...}", "created_at": "..." }
  ],
  "hasMore": false,
  "nextSeq": 121,
  "earliestAvailableSeq": 1
}
GET
/sessions/:id/logs
Detailed execution logs, including full model inputs. Kept 14 days, at most the newest 2000 per session
GET
/sessions/:id/trace
The full history with timing spans, as the console's trace view shows it
GET
/sessions/:id/runtime/spans
Timing spans. Query: since_seq, limit (up to 5000), turn_id, name
GET
/sessions/:id/otel
The session's spans as OpenTelemetry (OTLP/JSON), to load into your own tracing tool
GET
/fleet/overview
Health of the whole workspace over a time window: sessions, errors, latency and cost. Query: window = 1h, 6h, 24h (default), 7d or 30d
GET
/fleet/series
The same numbers over time, per hour or per day
GET
/fleet/problems
Sessions worth a look: failed, slow or expensive ones

# WebSocket

A session's WebSocket streams its turns live. Browsers can't set headers on a WebSocket, so the API key goes in as a subprotocol: offer parel-v1 plus token. followed by the key, and the server picks parel-v1. It takes a Write or Admin key. Older clients pass ?token= in the URL instead; avoid that, since URLs end up in logs.

GET
/sessions/:id/ws
Open the session WebSocket
▓ websocket
const ws = new WebSocket("wss://api.parel.sh/sessions/ssn_.../ws", [
  "parel-v1",
  "token." + apiKey,
]);
ws.onopen = () => ws.send(JSON.stringify({ type: "message", content: "Hello" }));
ws.onmessage = (event) => console.log(JSON.parse(event.data));

On connect the server sends a sync frame with the current history. After that:

FrameMeaning
{"type":"message"}You send: a message, as a string or parts (up to 1 MiB per frame). Answered with message_ack or error
{"type":"resume"}You send: ask for a fresh sync, for example after reconnecting
syncSession state and history (media bytes left out)
message_ackYour message was taken: status, inputId, and turnId when a turn started
textThe reply text as it streams
reasoning_deltaThe model's reasoning as it streams, between reasoning_start and reasoning_end
tool_call · tool_resultA tool the agent called, and what came back
input_absorbedA message joined the running turn
execution_pauseThe session stopped at a breakpoint
command_resultA slash command finished
errorSomething failed; may carry a code
turn_endThe turn is over. Keep reading until this frame before treating a turn as done

# Snapshots, branches and breakpoints

A snapshot saves a session at one point: its history, plugin state and waiting inputs. One is taken at the end of every turn, and you can take more. From a snapshot you can branch (a new session you can change: other input, model or runtime settings) or replay (run it again to reproduce a problem). The original session is never changed. Breakpoints (pause policies) stop a session at a chosen point until you resume or cancel.

GET
/sessions/:id/execution/snapshots
List a session's snapshots
POST
/sessions/:id/execution/snapshots
Take a snapshot now. Body (optional): label, metadata, idempotencyKey
GET
/execution/snapshots/:snapshotId
Get one snapshot with its contents
POST
/execution/snapshots/:snapshotId/branches
Start a branch session from a snapshot. Body: input and run: true to run it, mutations, idempotencyKey
POST
/execution/snapshots/:snapshotId/replays
Start a replay session from a snapshot (same body as a branch)
GET
/execution/branches/:branchId
Get a branch or replay record, including the new session's id
GET
/sessions/:id/execution/pause-policies
List a session's breakpoints
POST
/sessions/:id/execution/pause-policies
Add a breakpoint. Body: anchor, condition, oneShot, label
DELETE
/execution/pause-policies/:policyId
Remove a breakpoint
GET
/sessions/:id/execution/pauses
List the times a session stopped at a breakpoint
GET
/execution/pauses/:pauseId
Get one pause
POST
/execution/pauses/:pauseId/resume
Continue after a pause. Body: input (optional; starts a new turn with it)
POST
/execution/pauses/:pauseId/cancel
Cancel a pause; the session ends there

Breakpoints can stop at step_start, before_model, after_model, before_tool and after_tool, optionally only for one toolName or stepNumber. oneShot: true removes the breakpoint after it fires once.

▓ execution
POST /sessions/ssn_.../execution/pause-policies
{ "anchor": "before_tool", "condition": { "toolName": "bash" }, "oneShot": true }

POST /execution/pauses/<pauseId>/resume
{ "input": "Go ahead, but skip the deploy step" }

POST /execution/snapshots/<snapshotId>/branches
{
  "run": true,
  "input": "Try again with stricter validation",
  "mutations": { "modelOverride": "<model id>",
                 "runtimeConfigOverride": { "maxSteps": 20 } }
}
→ 201 { "id": "...", "mode": "branch", "branchSessionId": "ssn_...", ... }

# Channels (beta)

A channel lets an outside system start or continue sessions. A connection is the link to that system; a binding sends its events to an agent. There are two kinds of connection: webhook, where the system posts events to PAREL, and managed_ws, an always-on connection that PAREL keeps open to the system. A connector plugin speaks the system's protocol; generic-webhook and generic-ws are built in.

A webhook connection needs config.webhookSecret set to a reference such as ${WEBHOOK_SECRET} that names a secret already set for the workspace; events are routed only when their signature checks out. Always-on (managed_ws) connections need the Hobby plan (up to 3) or Pro; without one, creating or starting such a connection returns 402 plan_limit.

Connections

GET
/channels/connections
List connections
POST
/channels/connections
Create a connection. Body: type, plugin, pluginVersion, config, start (managed_ws only), pluginArtifacts
GET
/channels/connections/:connectionId
Get one connection
PATCH
/channels/connections/:connectionId
Change a connection in place: config (replaced whole), pluginVersion or pluginArtifacts. Its id, bindings and conversations stay; a running managed_ws connection restarts
DELETE
/channels/connections/:connectionId
Delete a connection; 409 while bindings still use it
POST
/channels/connections/:connectionId/start
Turn a connection on; a managed_ws connection dials out (502 if it can't connect)
POST
/channels/connections/:connectionId/stop
Turn a connection off
GET
/channels/connections/:connectionId/status
Live status of a connection
POST
/channels/connections/:connectionId/reset-runtime
Rebuild a stuck managed_ws connector from scratch; it reconnects by itself when it is meant to be on

Bindings

POST
/channels/bindings
Send a connection's events to an agent

agentId must be the agent's id. routingPolicy picks the session for each event: main (one shared session, the default), per_subject (one per thread or chat), per_actor (one per user) or isolated (a new one for every event). instanceKey picks the instance (default main). observe reports the agent's progress back to the connector: turn, steps, pause. injectInFlight hands new events from the same conversation to a running turn. childSessions lets the connector open child sessions. Use one binding per connection.

GET
/channels/bindings
List bindings
PATCH
/channels/bindings/:bindingId
Change observe, injectInFlight or childSessions in place; any other field is refused (delete and recreate the binding to point it elsewhere). Takes effect within 60 seconds
DELETE
/channels/bindings/:bindingId
Delete a binding
POST
/channels/bindings/:bindingId/reset
Start a conversation over: the next event for this key gets a new session, and the old one stays readable. Body: externalKey (not needed with main routing)
▓ channels
POST /channels/connections
{
  "type": "webhook",
  "plugin": "generic-webhook",
  "config": { "webhookSecret": "${WEBHOOK_SECRET}" }
}
→ 201 { "id": "chn_...", "type": "webhook",
        "plugin": "@parel/channel-generic-webhook", "status": "active" }

POST /channels/bindings
{
  "agentId": "agt_...",
  "connectionId": "chn_...",
  "routingPolicy": "per_subject",
  "observe": ["turn", "steps"],
  "injectInFlight": true
}
→ 201 { "id": "cbd_...", "agentId": "agt_...", "connectionId": "chn_...",
        "routingPolicy": "per_subject", "instanceKey": "main",
        "observe": ["turn", "steps"], "injectInFlight": true }

Events and deliveries

GET
/channels/events
List received channel events. Query: connectionId, sessionId, status, limit (up to 200)
GET
/channels/connections/:connectionId/events
What happened to one event, looked up by the connector's own event id (query: by_envelope_id): the routing result plus the turn receipt
POST
/channels/deliveries
Queue an outgoing message through a connection. Body: connectionId, sessionId, replyRoute, payload, idempotencyKey. Answers 202
POST
/webhooks/channels/:connectionId
Where the outside system posts its events. Public, no API key; the connector checks the signature

For the built-in generic-webhook connector, sign the raw request body with HMAC-SHA256 using the secret and send the hex digest in the x-parel-signature header (a sha256= prefix is fine). Accepted events get a 202.

▓ webhook
POST https://api.parel.sh/webhooks/channels/chn_...
Content-Type: application/json
x-parel-signature: sha256=<hex HMAC-SHA256 of the raw body>

{ "id": "evt-1001", "type": "ticket.created", "subject": "ticket-42", "data": { ... } }
→ 202

# API keys

GET
/api-keys
List keys with their prefix, scope, expiry and last use (never the key itself)
POST
/api-keys
Create a key. Body: name, scopes (read, write or admin; default write), expires_in_days (optional). The full key appears in this response only
POST
/api-keys/:id/revoke
Revoke a key: it stops working at once and the record stays
DELETE
/api-keys/:id
Delete a key
▓ api-keys
POST /api-keys
{ "name": "ci", "scopes": "write", "expires_in_days": 90 }
→ 201 { "id": "key_...", "name": "ci", "key": "pk_...",
        "key_prefix": "pk_1a2b...9f0e", "scopes": "write", "expires_at": "..." }

# Model provider keys

PAREL doesn't sell model access. Agents call models with your own keys and the model provider bills you directly. Keys are stored per provider id (anthropic, openai, openrouter and so on) and used by every agent in the workspace, unless its config sets model.config.apiKey.

GET
/providers
List model providers and whether the workspace has a key for each
GET
/provider-keys
List stored model provider keys (prefix only)
POST
/provider-keys
Add or replace the key for one model provider. Body: provider, key
DELETE
/provider-keys/:id
Delete a model provider key

# Secrets

Named values that agent configs reference as ${NAME}. They are stored encrypted and never returned, only a short prefix. A secret can be set for the whole workspace, for one agent (agentId), or for one instance of an agent (agentId plus instanceKey); the narrowest one wins. Writes need an Admin key.

GET
/required-secrets
List every ${NAME} your deployed agents reference, and whether it is set
GET
/secrets
List secrets: workspace, agent and instance ones
POST
/secrets
Create or replace a secret. Body: name (UPPER_SNAKE_CASE), value, agentId and instanceKey (both optional)
DELETE
/secrets/:id
Delete a secret
▓ secrets
POST /secrets
{ "name": "E2B_API_KEY", "value": "e2b_..." }
{ "name": "CRM_TOKEN", "value": "...", "agentId": "agt_...", "instanceKey": "acme" }

# Billing

Your plan meters steps (model calls the runtime made) and always-on connections each month. When the Free plan's steps or a paid plan's spend cap run out, new turns are refused with billing_insufficient.

GET
/billing/summary
Token and cost totals, by agent, by model and by day
GET
/billing/usage
Model call records, newest first. Query: limit (up to 200), offset
GET
/billing/plan
Your plan, this month's steps and connection-days, what the plan includes, the spend cap, and whether the limit is reached
PUT
/billing/spend-cap
Set the most you'll pay beyond the plan each month. Body: usd (0 to 10000). Hobby and Pro only
POST
/billing/checkout
Start a Hobby or Pro subscription. Body: plan. Returns a checkout url
POST
/billing/portal
Open the billing portal to change plan or card and see invoices. Returns a url
GET
/billing/balance
Legacy: always returns a zero balance; kept for older CLIs

# Rate limits

Requests are counted per minute in a fixed window. Each IP gets 6000 requests a minute and each API key 1200; going over either returns 429 rate_limited. Separately, while 25 of a workspace's sessions are running, starting another session returns 429 too. Every response carries these headers:

HeaderDescription
X-RateLimit-LimitThe limit that applies: 1200 with an API key, 6000 per IP without
X-RateLimit-RemainingRequests left in this window (the lower of the two counters)
X-RateLimit-ResetWhen the window resets, in Unix seconds

# Errors

An error is a JSON object: error is a message for people, code a stable value for programs, and some add details. Match on code, but expect it to be missing on a few errors and expect new codes over time; treat an unknown code as a general failure. A failed turn carries the same codes in its turn receipt, in the turn:error event and in the WebSocket error frame.

▓ error
HTTP/1.1 409
{ "error": "Session terminated", "code": "session_terminated" }

HTTP status

StatusMeaning
400The request is malformed or a value is invalid
401Missing, invalid, expired or revoked API key
402Your plan doesn't allow this (plan_limit)
403The key's scope doesn't allow this request
404Not found, or not in this workspace
409Clashes with the current state: a session that can't take a turn, a name already taken, an instance still in use
413The message or upload is too large
429Too many requests, running sessions or waiting inputs; try again later
500Something failed on our side
501Not available on this server
502A channel connector couldn't connect
503The server isn't set up for this request

Error codes

CodeWhereMeaning
invalid_request400A field is missing or has a bad value
unauthorized401No valid API key or token. The 401 also carries a WWW-Authenticate: Bearer header
forbidden403The key's scope is too low
not_found404No such thing in this workspace
rate_limited429 · turnToo many requests, running sessions or waiting inputs. As a turn error: the model provider rate-limited the call
internal500, 503Something failed on our side
config_invalid400 · deploy checkThe agent config doesn't parse or doesn't validate
secret_unresolved400 · deploy checkThe config references a secret that isn't set
secret_literal400 · deploy checkA plugin's secret field holds a plain value; use a ${NAME} reference
artifact_invalid400 · deploy checkA plugin artifact is wrong, or a local plugin was deployed without one
plugin_freeze_failed400 · deploy checkA plugin couldn't be resolved or packed
agent_name_existsdeploy check · warningAn agent with this name exists; deploying adds a version to it
var_deferreddeploy check · warningThe config uses ${var:NAME}; every instance has to supply the value
model_unsupporteddeploy check · turnUnknown model provider, or model settings it doesn't accept
model_var_unresolveddeploy check · turnThe model block uses an instance var that isn't set
model_var_invaliddeploy check · turnAn instance var in the model block has a bad value
runtime_var_unresolveddeploy check · turnThe runtime block uses an instance var that isn't set
runtime_var_invaliddeploy check · turnAn instance var in the runtime block has a bad value
plugin_var_unresolveddeploy check · warningA plugin's config uses a var that has no value
provider_authturn · deploy checkNo key for the model provider, or the provider refused it
model_errorturnThe model call failed
model_timeoutturnThe model call took too long
model_output_limitturnThe reply hit the output token limit
model_filteredturnThe provider's content filter stopped the reply
model_incompleteturnThe reply ended early or with unfinished tool arguments
context_window_exceededturnThe conversation no longer fits in the model's context window
billing_insufficient409The Free plan's steps are used up, or the spend cap is reached
billing_suspended409The workspace is suspended
plan_limit402Over the plan's limit of always-on connections
budget_exceeded409The instance's spending budget (runtime.instanceBudgetUsd) is used up
turn_limit409The session reached runtime.maxTurns
session_terminated409The session has ended
instance_not_found404requireExistingInstance was set and the instance doesn't exist
instance_generation_mismatch409expectedInstanceGeneration doesn't match: the instance was reset
media_invalid400A media part is malformed or its type isn't allowed
media_too_large413Media over the size or count limits
command_failedcommand resultA slash command failed
idempotent_replaywarningThis Idempotency-Key was already handled; you get the original result
missing_idempotency_keywarningNo idempotency key was sent, so a retry would run twice
media_not_visible_to_modelwarningThe current model can't see this media; it gets a text note in its place
unknown_commandwarningNot a known slash command; it was sent as a normal message
API Reference · PAREL