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:
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.
| Scope | Allows |
|---|---|
| read | GET requests only: it can read everything and change nothing. It can't open a session WebSocket. |
| write | Everything 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. |
| admin | Everything, 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.
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.
# 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 }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.
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" }
]
}# 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.
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.
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.
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.
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.
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.
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.
# 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/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/ssn_.../events?since_seq=120&limit=500
→ {
"events": [
{ "id": "evt_...", "seq": 121, "type": "turn:start",
"data": "{...}", "created_at": "..." }
],
"hasMore": false,
"nextSeq": 121,
"earliestAvailableSeq": 1
}# 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.
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:
| Frame | Meaning |
|---|---|
| {"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 |
| sync | Session state and history (media bytes left out) |
| message_ack | Your message was taken: status, inputId, and turnId when a turn started |
| text | The reply text as it streams |
| reasoning_delta | The model's reasoning as it streams, between reasoning_start and reasoning_end |
| tool_call · tool_result | A tool the agent called, and what came back |
| input_absorbed | A message joined the running turn |
| execution_pause | The session stopped at a breakpoint |
| command_result | A slash command finished |
| error | Something failed; may carry a code |
| turn_end | The 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.
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.
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
Bindings
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.
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
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.
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
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.
# 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.
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.
# 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:
| Header | Description |
|---|---|
| X-RateLimit-Limit | The limit that applies: 1200 with an API key, 6000 per IP without |
| X-RateLimit-Remaining | Requests left in this window (the lower of the two counters) |
| X-RateLimit-Reset | When 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.
HTTP/1.1 409
{ "error": "Session terminated", "code": "session_terminated" }HTTP status
| Status | Meaning |
|---|---|
| 400 | The request is malformed or a value is invalid |
| 401 | Missing, invalid, expired or revoked API key |
| 402 | Your plan doesn't allow this (plan_limit) |
| 403 | The key's scope doesn't allow this request |
| 404 | Not found, or not in this workspace |
| 409 | Clashes with the current state: a session that can't take a turn, a name already taken, an instance still in use |
| 413 | The message or upload is too large |
| 429 | Too many requests, running sessions or waiting inputs; try again later |
| 500 | Something failed on our side |
| 501 | Not available on this server |
| 502 | A channel connector couldn't connect |
| 503 | The server isn't set up for this request |
Error codes
| Code | Where | Meaning |
|---|---|---|
| invalid_request | 400 | A field is missing or has a bad value |
| unauthorized | 401 | No valid API key or token. The 401 also carries a WWW-Authenticate: Bearer header |
| forbidden | 403 | The key's scope is too low |
| not_found | 404 | No such thing in this workspace |
| rate_limited | 429 · turn | Too many requests, running sessions or waiting inputs. As a turn error: the model provider rate-limited the call |
| internal | 500, 503 | Something failed on our side |
| config_invalid | 400 · deploy check | The agent config doesn't parse or doesn't validate |
| secret_unresolved | 400 · deploy check | The config references a secret that isn't set |
| secret_literal | 400 · deploy check | A plugin's secret field holds a plain value; use a ${NAME} reference |
| artifact_invalid | 400 · deploy check | A plugin artifact is wrong, or a local plugin was deployed without one |
| plugin_freeze_failed | 400 · deploy check | A plugin couldn't be resolved or packed |
| agent_name_exists | deploy check · warning | An agent with this name exists; deploying adds a version to it |
| var_deferred | deploy check · warning | The config uses ${var:NAME}; every instance has to supply the value |
| model_unsupported | deploy check · turn | Unknown model provider, or model settings it doesn't accept |
| model_var_unresolved | deploy check · turn | The model block uses an instance var that isn't set |
| model_var_invalid | deploy check · turn | An instance var in the model block has a bad value |
| runtime_var_unresolved | deploy check · turn | The runtime block uses an instance var that isn't set |
| runtime_var_invalid | deploy check · turn | An instance var in the runtime block has a bad value |
| plugin_var_unresolved | deploy check · warning | A plugin's config uses a var that has no value |
| provider_auth | turn · deploy check | No key for the model provider, or the provider refused it |
| model_error | turn | The model call failed |
| model_timeout | turn | The model call took too long |
| model_output_limit | turn | The reply hit the output token limit |
| model_filtered | turn | The provider's content filter stopped the reply |
| model_incomplete | turn | The reply ended early or with unfinished tool arguments |
| context_window_exceeded | turn | The conversation no longer fits in the model's context window |
| billing_insufficient | 409 | The Free plan's steps are used up, or the spend cap is reached |
| billing_suspended | 409 | The workspace is suspended |
| plan_limit | 402 | Over the plan's limit of always-on connections |
| budget_exceeded | 409 | The instance's spending budget (runtime.instanceBudgetUsd) is used up |
| turn_limit | 409 | The session reached runtime.maxTurns |
| session_terminated | 409 | The session has ended |
| instance_not_found | 404 | requireExistingInstance was set and the instance doesn't exist |
| instance_generation_mismatch | 409 | expectedInstanceGeneration doesn't match: the instance was reset |
| media_invalid | 400 | A media part is malformed or its type isn't allowed |
| media_too_large | 413 | Media over the size or count limits |
| command_failed | command result | A slash command failed |
| idempotent_replay | warning | This Idempotency-Key was already handled; you get the original result |
| missing_idempotency_key | warning | No idempotency key was sent, so a retry would run twice |
| media_not_visible_to_model | warning | The current model can't see this media; it gets a text note in its place |
| unknown_command | warning | Not a known slash command; it was sent as a normal message |