docs/api.md
Rama main · réplica de lectura del repositorio original.
# The HTTP API
Everything the console does, it does through this API. There is no private
surface: the same endpoints are available to you.
The interactive reference is generated from the code and served by the
deployment itself at **`/docs`**, with the schema at
`/api/v1/openapi.json`. Both are on in development and off in production —
`ENVIRONMENT` decides, so a production deployment does not publish its own
route list.
## Authenticating
Three ways in, for three different callers.
| | Header | For |
|---|---|---|
| **Organization API key** | `Authorization: Bearer aos_…` | A script, an HTTP client such as Postman, or an MCP client. Acts as the member who issued it, narrowed to the permissions it was issued with |
| **JWT** | `Authorization: Bearer <access token>` | A person, or something acting as one. Short-lived, refreshed with a refresh token |
| **Session cookie** | set by the console | The browser only — the token is HttpOnly and never reaches JavaScript |
Use `Authorization: Bearer aos_…` for organization keys. `X-API-Key` is a separate deployment
service credential; sending an `aos_…` key there does not authenticate an organization request.
Vault routes such as `GET /api/v1/secrets` and the console messages route require a signed-in
session. API clients read conversation messages through `GET /api/v1/conversations/{id}/transcript`.
### Organization API keys
A key is issued by a member, in one organization, from **Settings → API**
(or `POST /api/v1/api-keys` from a signed-in session). It carries that member's
authority, narrowed twice:
- **To the permissions it was issued with.** Pick a preset — *Read-only*,
*Knowledge ingest*, *Full access* — or tick permissions from the
[catalog](permissions.md). You can only grant what you hold.
- **To what the issuer may do now.** Every request re-reads the issuer's
membership, so demoting them narrows each of their keys at once. Removing them
from the organization, or their leaving it, revokes every key they issued and
every app they connected - for good, so inviting them back wakes none of it. A resource grant
widens what a *person* may do to one row; it never widens a key past its
permissions.
The key is shown **once**, in the response that creates it. Only a SHA-256 of it
is stored, and it never appears in a log line, an audit entry or an error body.
Lists show its prefix (`aos_1a2b3c4d`), which is also how an audit entry names
the key that acted — every entry recorded during a key-authenticated request
carries `via_api_key` in its details. A key can have an expiry, and revoking it
(`DELETE /api/v1/api-keys/{id}`) takes effect on its next request.
**A key is not its issuer's person.** A run it starts is owned, budgeted and
audited as the issuer, but it reaches none of their personal state: no
[memory](reference/capabilities.md#whose-notes-and-who-may-hear-them), no search
of their past conversations, no sites they kept signed in for the browser, and
no user-scoped sandbox workspace. Whoever holds the key is a script, not the
person, so the run answers as it would on a public widget.
Approving personal routines and watches requires the explicit `personal:consent` scope, selected under **Custom** when issuing a key or authorizing an MCP client. It is excluded from every preset, including **Full access**. The confirmation allows the credential to approve persistent routines and watches using your personal connections and data; ordinary `agents:run` and `runs:view` scopes do not. Existing keys and MCP authorizations gain no personal consent automatically.
**Setting up work that acts as you is yours to do with a key you issued.** A
key you created, or an MCP client's token you authorized, may create and change
your own [routines](triggers.md) that act as you (`runs_as_creator`) and your
[watches](console.md), review a routine's sources, and publish an agent without
withdrawing the consent of routines you made to act as you. The run those
routines start is still the routine's, under its reviewed sources and budget.
An agent's own run credential cannot do this outside your private chat, and
neither can an administrator impersonating you.
Conversations are public too: `GET /api/v1/conversations` lists the issuer's
chats (`runs:view`), and `POST /api/v1/conversations` with a `title` and
`PATCH /api/v1/conversations/{id}` open and rename one (`agents:run`) - for
example a topic chat a routine answers in. `POST /api/v1/me/mcp-connections/{id}/test`
checks one of your own connections and refreshes its tools (`agents:run`).
Read-only connection discovery is also public: `GET /sandbox-connections`,
`GET /sandbox-connections/runtimes` and `GET /sandbox-connections/{id}/policy`
require `connections:view`; `GET /mcp-connections` requires `mcp:manage`.
Organization and key scopes still apply. Connection writes and credential
operations remain session-only.
```bash
curl "$BASE/api/v1/me/permissions" \
-H "Authorization: Bearer $AGENTICOS_KEY"
```
Two refusals are worth knowing:
- **`403` "API keys are not accepted on this endpoint"** — keys are accepted on
the public API only: agents, runs and approvals, knowledge bases and RAG, skills,
context files, projects, apps, the ML services, `/me/permissions`, and an
organization's members, invitations, groups and settings. The console's own
routes, your account, leaving or handing over an organization, and key
management itself stay session-only, so a leaked key cannot mint its successor.
- **`401` "Invalid, expired or revoked API key"** — the same sentence for each
case, so a wrong key learns nothing about which keys exist.
Each key is also rate limited on its own, `RATE_LIMIT_API_KEY_PER_MINUTE`
requests a minute (600 by default), and a run or an ML call it makes counts
against those limits for the key rather than for its issuer. Both checks run
before the key is looked up, on a request and on a socket handshake: a key is
counted by a digest of the whole token - never by its prefix, which listings show -
and an address that has sent `RATE_LIMIT_AUTH_PER_MINUTE` keys that do not exist is
answered `429` without a lookup until its minute is over.
### Without a browser
A script can get its own key with no console open. Sign in with a form body (the
OAuth2 password grant, so JSON answers `422`), pick the organization, issue the
key with the session token, and revoke it the same way when it is done:
```bash
SESSION=$(curl -s -X POST "$BASE/api/v1/auth/login" \
-d "username=$EMAIL" --data-urlencode "password=$PASSWORD" | jq -r .access_token)
ORG_ID=$(curl -s "$BASE/api/v1/orgs" -H "Authorization: Bearer $SESSION" \
| jq -r '[.items[] | select(.is_personal)][0].id')
curl -s -X POST "$BASE/api/v1/api-keys" \
-H "Authorization: Bearer $SESSION" -H "X-Organization-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d '{"name": "nightly import", "scopes": ["collections:view", "collections:edit"]}'
# -> {"id": "...", "key": "aos_...", "prefix": "aos_1a2b3c4d", ...}
curl -s -X DELETE "$BASE/api/v1/api-keys/$KEY_ID" \
-H "Authorization: Bearer $SESSION" -H "X-Organization-Id: $ORG_ID"
```
`GET /api/v1/api-keys/scopes` lists what the signed-in member may put on a key,
and the presets over it. A deployment that signs people in only through SSO has
no password to send; there a member issues the key in the console.
[`examples/public-api.sh`](examples/public-api.sh) is the whole round trip as
one script: it issues a read-only key, lists agents and runs with it, shows the
`403` for a route outside the key's permissions, runs an agent and reads the run
back when `AGENT_ID` is set, and revokes the key on exit.
```bash
BASE=http://localhost:8000 EMAIL=you@example.com PASSWORD=... bash docs/examples/public-api.sh
```
### Sessions and revocation
A JWT access token is bound to the session its sign-in opened — the session's id
travels inside the token. Signing out everywhere (`DELETE /sessions`) deactivates
those sessions, and a bound token is then refused on its next use rather than
living out its few remaining minutes. That reaches an open chat WebSocket too: the
next frame on a revoked session closes the socket, not only the next HTTP request.
Refreshing does not start a new session — the refresh token rotates in place and
the access token keeps naming the same one — so a long-lived connection is not cut
off by a routine refresh.
### Work that runs after the request { #work-that-runs-after-the-request }
An organization key cannot start an [eval run](evals.md) or a request to the
[authoring assistant](authoring.md): those routes are for people signed in, and
a key is answered `403` on them. Work that runs later, in the background, is
held to its starter's authority as it stands when the work starts - a role lost
since stops it. Were a key ever let start such work, the same would hold for
the key: its scopes as they stand then, and a key revoked, expired or deleted
since stopping the work, with a reason on the run or the request.
## The organization header
**An API key acts in its own organization**, and needs no header. Sending
`X-Organization-Id` with a key is allowed only when it names that same
organization; naming another one answers `400` with
`details.header = "X-Organization-Id"` rather than switching tenant.
**A session token takes the tenant from `X-Organization-Id`.** A caller who
belongs to three organizations is a different principal in each, with a
different role and different grants, so send the header on every request. When
it is absent the request falls back to the deployment's **default organization**
if the caller belongs to it, then to their **personal organization**, then to
their only one — a script that forgets the header acts there, with whatever
agents, grants and budget that organization has, and gets no error. A caller who
belongs to several and has neither gets a `400` with
`details.header = "X-Organization-Id"` rather than a guess
([where a new account starts](deployment.md#where-a-new-account-starts)). Send
the wrong one and you get a refusal that looks exactly like the resource not
existing — deliberately, so ids stay unprobeable.
## Running an agent
```bash
curl -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "How do I rotate a provider key?"}'
```
The response carries the run id, the output, the status, the tokens and the cost.
`environment_id` in the body picks [which environment](environments.md) answers.
An agent with an [answer format](concepts.md#spec) answers with an object: it is in
`structured`, already validated against the agent's `output_schema`, and `output`
shows the same object as a JSON block.
`"draft": true` runs the unpublished draft instead, as the Builder's test panel
does: a test run, for a key whose issuer may edit the agent, refused with the
reason when the draft would not publish, and never with `Prefer: respond-async`.
Synchronous runs, including draft tests, return `status: awaiting_answer` and `questions` when the
agent asks the person. Answer with `POST /api/v1/runs/{run_id}/answers`, using
`{"responses":{"<tool_call_id>":[{"selected":["Choice"]}]}}`; use `answer` for custom text or
`skipped: true` to skip deliberately. No response is treated as a refusal automatically. Platform
MCP exposes the same flow as `test_agent_draft` and `answer_agent_questions`.
Conversation history retains answered question cards and successful deferred capability loads.
Current registered skills resolve their current instructions on the next turn; revoked skills are
not re-enabled. Historical table results are not cached as current data. Top-level agent `starters`
describes the published version; draft starters live under `draft_spec.starters`.
A key also reaches an agent's routines - `GET /api/v1/triggers` and
`GET`, `POST`, `PATCH` and `DELETE` under `/api/v1/agents/{agent_id}/triggers`,
plus `POST .../{trigger_id}/run` to fire one now - and the organization's models
at `GET /api/v1/providers/model-profiles`. Three routes act on the issuer
themselves: `GET /api/v1/me/mcp-connections` lists the services they connected
with their own account, `PUT /api/v1/users/me/locale` sets the console's
language for them on every device, and `PATCH /api/v1/users/me/agent-context`
saves their role (`job_title`) and what they work on (`about`), which every
agent is told. Each needs a key issued for `agents:run`.
`POST /api/v1/agents` creates an agent from the spec in its body, and keeps an
empty field empty. With `"from_starter": true` it fills what the spec leaves out -
the instructions, skills, the platform's own tools and the budget - from the
AgenticOS starter agent, as **New agent** in the console does.
**A run continues a conversation.** A call without `conversation_id` opens a new
conversation for the key's issuer and returns its id as `conversation_id`; send
it with the next call and the agent reads what was said before - the recent
turns, or the summary of older ones and what came after. A workspace shared per
conversation, the default, is that conversation's. An unknown id, or somebody
else's, answers `404`; one of the issuer's own conversations from the console or a
channel answers `400` - a key that may run agents was not given the person's chats.
So does a parked run: a key resumes or answers only a run a key started, and
one the person started answers `403`, since continuing it acts as them.
The conversation is filed in the issuer's chat list, labelled **API** and hidden
until they switch such conversations on there.
A run asked in a chat is the exception to resuming it yourself. When a person
decides its last pending approval, or answers its questions, the server continues
the run as them and posts the answer to the chat. A key's decision continues
nothing: a person has to resume that run from the console within about an hour of
the last decision, or it is ended.
`continues_in_chat` says so: it is on `ApprovalRead`, on `AgentRunRead` (the single
read) and on the result of a resume and of an answer. It is always false on
`POST /agents/{id}/run`, and for an API key. When it is true, do not call
`/resume`. A `/resume` that arrives second gets the existing `400`, with the run's
`status`. See [Decided outside the chat](channels.md#decided-outside-the-chat).
`GET /api/v1/conversations/{id}/transcript` reads one of the issuer's
conversations turn by turn, with the tools each turn called and what it cost,
for a key holding `runs:view`.
!!! info "An API caller cannot route around governance"
This endpoint goes through the same runner as the console, Slack and the
widget. The run is recorded, the budget is checked before the model request,
the approval gate applies, and the cost lands in the same dashboard.
That is the point of one runner, and it is why there is no "fast path" that
skips it.
### Without holding the connection
A run of minutes - a browse, a long tool chain - holds the request open the
whole time, and a proxy's read timeout can cut it off while the run goes on.
Send `Prefer: respond-async` and the call answers `202` at once instead, with a
run request to poll:
```bash
curl -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-H "Prefer: respond-async" \
-H "Idempotency-Key: ticket-4711" \
-d '{"prompt": "Summarise this quarter for the board"}'
# 202, Location: /api/v1/run-requests/{id}, Preference-Applied: respond-async
curl "$BASE/api/v1/run-requests/$REQUEST_ID" -H "Authorization: Bearer $AGENTICOS_KEY"
```
The request reads `queued`, `running`, then `completed` with `result` - the
same body the synchronous call answers - or `failed` with `error`. Its
`conversation_id` is known from the first answer, so a follow-up can be sent
before the run ends. A worker runs it through the same runner, budget and
approvals as every other surface.
What can be checked at once is checked at once: an unknown conversation or
attachment is a `404` on the `POST`, not a failure to poll for. The key's
authority is checked again when the run starts, so a key revoked while its run
waited does not run it. Only the person who asked, or a key of theirs, can read
a run request; anyone else's answers `404`. An asynchronous run gets one
attempt - a run that failed is recorded with what it spent, and is not retried
into spending it twice.
The route carries a **rate limit rather than a permission gate**. Permission is
decided inside the service, against that specific agent's grants — a role gate
on a per-resource route [cannot see them](permissions.md).
`PATCH /api/v1/agents/{id}/metadata` sets an agent's **categories** and **tags**
with a body like `{"categories": [...], "tags": [...]}`, where an empty list
clears that facet. Values are normalized — trimmed, whitespace-collapsed,
case-folded and de-duplicated — and bounded: at most 10 categories and 20 tags,
each at most 32 characters, a longer item answering `422`. Like the run route it
carries no role gate; the grant-aware `agents:edit` check inside the service
decides, so a viewer holding an edit grant on one agent may retag it.
`GET /api/v1/agents` filters that catalog through repeatable `category` and `tag`
query parameters: values **OR within a facet** and **AND across facets**, matched
case-insensitively (a query value folds the way a stored one does, and a blank
value is ignored). The filter only narrows what you could already see — it never
crosses a tenant or a grant boundary.
The response also carries `categories` and `tags`: every distinct label on the
agents you could list, whatever the filter and the page — the choices a filter
menu offers. A private agent you cannot see adds none.
## Running a workflow
Organization API keys may start runs and read their status, graph, steps, files
and events through `/api/v1/workflow-runs`. Start with `workflows:run`; reading also
needs `workflows:view`, and draft tests need `workflows:edit`. The worker retains
the key's admitted scope ceiling and rechecks the key and the issuer on every
step, including after an approval or wait. Revocation, expiry or deletion stops
new work with `PRINCIPAL_REVOKED`; it never promotes a continuation to the issuer's
full role. Key-started runs have no access to the issuer's personal state. Workflow
authoring remains session-only.
API keys can list and read public-origin runs, including runs started by keys;
member-started personal runs return `404`, including their graph, steps, events,
files, retry and cancellation endpoints. A key cannot import files from those runs.
```bash
curl -X POST "$BASE/api/v1/workflow-runs" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d '{"workflow_id": "'"$WORKFLOW_ID"'", "input": {"question": "How long do refunds take?"}, "deadline_seconds": 3600}'
```
This starts a run of the workflow's published version and answers `201` at
once; the nodes run in the background. `input` is what the graph's
[`core.input`](reference/workflow-nodes.md#core-input) trigger hands on, at most
`WORKFLOW_RUN_MAX_INPUT_BYTES` as JSON (`413` past it). Only a version that starts
from that trigger, or from none, is started here: any other answers `409
WORKFLOW_TRIGGER_MISMATCH`, because a webhook, a schedule, a chat message or a
table record starts it. `"mode": "test"` runs the current draft instead, from any
trigger, and needs `workflows:edit`.
Before calling, read `GET /api/v1/workflows/{id}/input-schema` with
`workflows:view`. It returns the published `version_id` and an `entries` list with
`entry_node_id`, `trigger` and effective `input_schema` for each Manual, API,
Called by a workflow or Form entry. Pass `version_id` to inspect a specific
published version. JSON Schema contracts are returned unchanged; simple fields
are represented as JSON Schema, and untyped entries return `{"type": "object"}`.
Use the intended entry's contract and pass its `entry_node_id` when starting.
Legacy simple `integer` fields additionally require JSON integer tokens: `1`
is accepted, while `1.0` and `1e0` are refused. Discovery marks these fields with
`x-agenticos-strict-integer: true`, including inside optional nullable fields.
Ordinary JSON Schema cannot assert this token distinction, so schema validation
alone is insufficient for those legacy fields; server admission remains authoritative.
Raw JSON Schema contracts use standard integer semantics instead.
The optional [JSON Schema input contract](reference/workflow-nodes.md#json-schema-input-contracts)
is checked before admission. A mismatch answers `422 WORKFLOW_RUN_INPUT_INVALID`
with field paths and messages in `error.details.fields`; no run is created.
Each entry has `field` and `message`; nested request paths include array indices,
for example `input.items.0.sku`. For existing callers, `error.details.problems`
also retains `field`, `message` and `type` with paths relative to the input object.
The contract comes from the current published version, or from the draft for
`"mode": "test"`. It applies equally over WebSocket and MCP and when another
workflow calls it.
`deadline_seconds` (up to thirty days) sets
a deadline that is checked each time a node is about to be dispatched: the
first node due after it passes fails the run with `DEADLINE_EXCEEDED`, while a
node already running, or a run parked on an approval, is not interrupted by it. The route is rate-limited per caller like the agent run route,
answering `429` with `Retry-After` past the allowance.
`GET /api/v1/workflow-runs/{id}` returns the run's status, `spent_cost`,
`error` and, once its [`core.output`](reference/workflow-nodes.md#core-output)
node has run, its `output`, and `POST /api/v1/workflow-runs/{id}/cancel` stops it. `GET
/api/v1/workflow-runs/{id}/events?after=<cursor>` returns the run's event stream
oldest first, with a `next_cursor` to pass back as `after`: it stays the same
while nothing newer exists, so polling with it tails a live run. Who may do each
of these is in [Permissions](permissions.md#workflow-runs).
`GET /api/v1/workflow-runs/{id}/nodes` lists every step the run took, loop
iterations included, each with its `scope_path`, status, tries, cost and the typed
error it last failed with, and `GET /api/v1/workflow-runs/{id}/graph` returns the
graph the run executes: its version's, or a test run's draft snapshot.
### Reading a workflow's log { #reading-a-workflows-log }
The lines a workflow's [Quick Logs](workflows.md#the-logs-page) wrote can be read from
outside the console - by a script, a spreadsheet, or a dashboard tool such as Grafana or
Metabase - with an organization API key holding `workflows:view`.
A key reads the lines of public-origin runs, including runs started by keys: those
started by any organization key, those started from outside (a webhook, form, channel
message, Gmail, Outlook or GitHub event) and any run in a chain one of those started.
Lines of runs a member, a schedule, a table record, a chat, an agent or an app started
are not included, so a scheduled workflow's log reads empty through a key and its counts
differ from the console's. A workflow of another organization, or one the key's member
may not view, answers `404`; a key without `workflows:view` is refused.
```bash
# The newest lines, 100 at a time (the most a page holds); test runs are left out
curl "$BASE/api/v1/workflows/$WORKFLOW_ID/logs?outcome=error&from=2026-10-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID"
# How many lines each day left, by outcome, over the last 30 days in Warsaw's days
curl "$BASE/api/v1/workflows/$WORKFLOW_ID/logs/summary?days=30&tz=Europe/Warsaw" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID"
```
`GET /api/v1/workflows/{id}/logs` lists newest first - by `created_at`, then by `seq`,
the position a line took in the log as it was written. Narrow it with `node_instance_id`
(one step), `outcome` (a port, or `error`; repeat it to match any of several, and a
line that left by several ports matches each of them), `run` (a run id, or its first 4 or more
characters), `q` (text in the message) and the time window `from` (inclusive) and `to`
(exclusive); `include_test=true` adds the lines test runs wrote. `skip` and `limit` page
through it - `limit` is at most 100 - and `total` counts every matching line, so a
client pages until `skip` reaches it.
`GET /api/v1/workflows/{id}/logs/summary` is the data a chart wants: for the last
`days` days (7, 30 or 90, today included) it answers one entry per day, oldest first,
with the count each outcome left, and a day with no lines is still present. `tz`, an
IANA name, sets whose days are counted and is UTC by default; the other filters are the
list's. To pull it into a dashboard tool, point a JSON data source at the route with the
key as a bearer header and read `by_day`.
```bash
# Errors and timeouts per day for a month: one JSON data source for a dashboard panel
curl "$BASE/api/v1/workflows/$WORKFLOW_ID/logs/summary?days=30&outcome=error&outcome=timeout" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID"
```
`GET /api/v1/workflow-runs/{id}/logs` lists one run's lines oldest first by `seq`
(`limit` up to 500), and `GET /api/v1/workflow-logs` the newest lines across every
workflow the key may view, each with its workflow's name, plus how many lines each
outcome left in the last 24 hours. The alert rules on a log are not part of the public
API.
### Exporting and importing a workflow { #exporting-and-importing-a-workflow }
`GET /api/v1/workflows/{id}/export` (`workflows:view`) returns the draft as a
portable file: `name`, `description`, `tags`, `settings`, `graph` and
`unresolved`, the step and field of every resource pin it left out. It carries no
id of this deployment and no secret value. `POST /api/v1/workflows/import`
(`workflows:create`) takes that file and answers `201` with the new draft
`workflow` and its `unresolved` pins; a graph that does not parse, or names a step
this deployment does not have, is a `400` and makes nothing.
### Following a run over a WebSocket { #following-a-run-over-a-websocket }
`/api/v1/ws/workflow-runs?organization_id=<org>` authenticates like the chat's
socket, with the access token as the `access_token.<token>` subprotocol. Send
`{"type": "start", "workflow_id": ..., "input": {...}}` to start a run, or
`{"type": "attach", "run_id": ..., "after": <cursor>}` to follow one. The server
sends `{"type": "run", "run": {...}}` when it starts following and again when the
run ends, and `{"type": "event", "event": {...}, "cursor": ...}` for every event
between. A refused frame gets `{"type": "error", "code": ..., "message": ...}`,
and a revoked session closes the socket with `4001`. One socket follows one run;
a new frame replaces the run it was following. A `start` frame spends the per-minute
allowance `POST /workflow-runs` does, and past it gets `RATE_LIMIT_EXCEEDED`.
### Webhooks and schedules { #workflow-webhooks-and-schedules }
Webhook and GitHub delivery IDs survive run-content retention as minimal tombstones owned by the exposure and organization. A duplicate still returns the original run UUID after its content is deleted; its status and previous webhook response may no longer be available. Deleting the exposure or organization ends that deduplication lifetime. Migration `0230_webhook_delivery_tombstones.py` cannot restore claims already deleted by older retention; downgrading removes orphan tombstones before restoring the old cascading foreign key.
Unattended trigger publication and authority changes require a member session (`403` for an organization API key). Keys may publish manual workflows, start runs within their scopes and pause exposures, but cannot enable them, rotate secrets/form links, retime schedules or choose a new error-workflow identity. These operations retain a user's authority for future events.
Public workflow form submissions enforce the workflow input byte limit while reading the request stream, before JSON parsing or token lookup. Oversized declared or chunked bodies return `413`; malformed JSON and invalid submission fields return `422`. Each entry of a `422`'s `details.problems` is `{field, message, type}`, where `type` is the validator's own error type (`missing`, `int_type`, `extra_forbidden`, ...), so a form can say per answer what it needed.
A workflow whose trigger node is a webhook or a schedule gets its exposure when
that version is published, and the publish response carries it as `exposure`. A
webhook's signing secret is in the publish's `webhook_secret` the first time it is
switched on, and nowhere else. `GET /api/v1/workflows/{id}/exposure` reads it back,
or `null` for a workflow that starts another way. `PATCH
.../exposures/{exposure_id}` with `{"is_active": false}` pauses it, and `POST
.../exposures/{exposure_id}/rotate-secret` replaces a webhook's secret and returns
the new one once. Both need `workflows:edit` and `workflows:run` on the workflow. A
webhook's `webhook_url` is where the sender delivers:
```bash
BODY='{"lead": 42}'
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)"
curl -X POST "$WEBHOOK_URL" \
-H "X-Signature-256: $SIGNATURE" \
-H "X-Delivery-Id: lead-42" \
-H "Content-Type: application/json" \
-d "$BODY"
```
It answers `202` with `{"run_id": ..., "duplicate": false}` as soon as the run is
admitted, never waiting for the run itself. A delivery id already admitted answers
`"duplicate": true` with the first run's id. A signature that does not verify is a
`403`, a delivery with no id or with a body that is not a JSON object a `400`, and
a paused or unknown webhook a `404`.
A graph with a [Respond to webhook](reference/workflow-nodes.md#webhook-respond)
step answers with that step's status, headers and JSON body instead, and a retry
of the delivery gets the same answer. The request waits for it for up to
`WORKFLOW_WEBHOOK_RESPONSE_TIMEOUT_SECONDS`, then answers `202` as above; a run
that fails, is cancelled or runs out of budget before answering is a `500`
`WORKFLOW_WEBHOOK_UNANSWERED` naming the run.
Before publishing, `POST /api/v1/workflows/{id}/webhook-test` (`workflows:edit`)
opens a test URL for the draft, returned as `url` with the `test_token` in it and open
for one call for two minutes. The call needs no signature, is answered
`{"captured": true}` and starts no run. `GET .../webhook-test/{token}` reads back
`state` (`listening`, `caught` or `expired`) and the `delivery` it caught.
## Working with tables { #working-with-tables }
A record's `values` are keyed by column id; `GET /api/v1/tables/{id}` lists the
columns. Every write takes an `Idempotency-Key`: a retry with the same key and body
answers with the first write's result and `Idempotent-Replayed: true` instead of
writing again, and the same key with a different body is `422`.
```bash
curl -X POST "$BASE/api/v1/tables/$TABLE_ID/records" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Organization-Id: $ORG_ID" \
-H "Idempotency-Key: lead-ada-2026-09-29" \
-H "Content-Type: application/json" \
-d '{"external_id": "ada@example.com", "values": {"'"$EMAIL_COLUMN"'": "ada@example.com"}}'
```
`PATCH .../records/{record_id}` changes some cells and needs the
`expected_revision` you last read; a stale one is `409 REVISION_CONFLICT`. `PUT
.../records/by-external-id/{external_id}` creates the record or updates it, with
`expected_revision` required once it exists. `POST .../records/query` filters,
searches and sorts one page at a time, and `POST .../records/count` says how many
records the same filters and search match, up to 100,000.
A workflow whose trigger node is **Table record** runs, once it is published, for
the records of its table added, changed or deleted - whichever its trigger names. `GET .../triggers` lists the workflows that
start from a table, `PATCH .../triggers/{trigger_id}` with `{"is_active": false}`
pauses one, and `GET .../triggers/{trigger_id}/admissions` lists what it decided
about each record. See [Triggers](virtual-tables.md#triggers).
## Agents over A2A
An agent its organization serves over A2A answers JSON-RPC at
`POST /api/v1/a2a/{agent_id}`, with the same keys as `/run`; its agent card is at
`GET /api/v1/a2a/{agent_id}/.well-known/agent-card.json` and needs none.
`PUT /api/v1/agents/{id}/a2a` with `{"enabled": true}` turns it on, for a caller
holding `agents:publish` on that agent.
```bash
curl -X POST "$BASE/api/v1/a2a/$AGENT_ID" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "id": 1, "method": "message/send",
"params": {"message": {"role": "user", "messageId": "m1",
"parts": [{"kind": "text", "text": "Review clause 7"}]}}}'
```
The answer is a `Task` whose id is the run's and whose `contextId` continues the
conversation. `tasks/get` reads one again. [Channels](channels.md#a2a) has the
rest.
Remote agents this organization delegates to are registered at
`/api/v1/external-agents`: `GET` lists them (`agents:view`); `POST` with a
`card_url`, an optional `slug` and an optional API key `secret_id` reads the card
and registers it, and `POST /{id}/refresh` reads it again; `DELETE /{id}` removes
one (`agents:edit` for all three).
## The ML services
Four of the platform's services answer on their own, with no conversation and no
agent behind them: document analysis, OCR, speech to text and personal data
detection. They are gated on `ml:invoke` rather than `agents:run`, and
[The ML services](ml-services.md) is their reference.
```bash
curl -X POST "$BASE/api/v1/ml/privacy/pii" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "write to ada@example.com"}'
```
## Worked examples
Each one runs with an organization key in `$AGENTICOS_KEY` and the API's origin
in `$BASE`. Create the key under **Settings → API** with the permissions the
example names; the console shows the key once.
**Put a document into a knowledge base and search it** — a key with
`collections:view` and `collections:edit` (the *Knowledge ingest* preset).
Ingestion runs in the background, so a search right after the upload may not find
the document yet; `GET /api/v1/kb/$KB_ID/documents` shows its status, and
`GET /api/v1/kb/$KB_ID/ingestion` answers `complete` once none is still processing.
```bash
# Find the knowledge base, upload a file into it, and search it.
curl "$BASE/api/v1/kb" -H "Authorization: Bearer $AGENTICOS_KEY"
curl -X POST "$BASE/api/v1/kb/$KB_ID/documents" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-F "file=@policy.pdf"
curl -X POST "$BASE/api/v1/rag/search" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d "{\"collection_name\": \"$COLLECTION_NAME\", \"query\": \"refund window\"}"
```
**Run an agent and read what it cost** — `agents:run` and `runs:view`. The run
read carries its status, its tokens and its cost.
```bash
RUN_ID=$(curl -s -X POST "$BASE/api/v1/agents/$AGENT_ID/run" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "Summarise this week'"'"'s tickets"}' | jq -r .run_id)
curl "$BASE/api/v1/runs/$RUN_ID" -H "Authorization: Bearer $AGENTICOS_KEY"
```
**Invite a member** — `members:manage`. The invitation is emailed; the response
carries its token once, for an inviter whose mail may not arrive.
```bash
curl -X POST "$BASE/api/v1/orgs/$ORG_ID/invitations" \
-H "Authorization: Bearer $AGENTICOS_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "new.hire@example.com", "role": "member"}'
```
**Retry what was rate limited, and nothing else.** A `429` carries
`Retry-After`; every other refusal is final for that request, and a `401` means
the key is gone — revoked, expired, or its issuer removed — so retrying it only
spends the limit.
```python
import time
import httpx
def call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response:
for _ in range(5):
response = client.request(method, path, **kwargs)
if response.status_code != 429:
response.raise_for_status()
return response
time.sleep(int(response.headers.get("Retry-After", "60")))
response.raise_for_status()
return response
client = httpx.Client(
base_url="https://agenticos.example.com/api/v1",
headers={"Authorization": f"Bearer {KEY}"},
)
print(call(client, "GET", "/me/permissions").json())
```
## Streaming
Two WebSocket endpoints, for two audiences.
- **`/api/v1/ws/agent`** — the authenticated one the console uses. A frame
carrying `agent_id` runs that published agent; a frame without one gets the
general assistant. A frame's `conversation_id` continues that conversation;
`null` starts a new one, and leaving the key out continues the one this socket
last wrote to. Authenticate with the subprotocol `access_token.<token>`,
where the token is a session JWT. An organization API key is refused here: a
turn on this socket is a person at the keyboard, with their personal
connections. Integrations run agents with `POST /api/v1/agents/{id}/run`.
- **`/api/v1/embed/{public_key}/ws`** — the public one behind an
[embed](channels.md), for a visitor who has no account.
Both stream tokens as they arrive (an agent with an output guardrail streams a step
at a time, see [Guardrails](reference/capabilities.md#guardrails)) and both produce
an ordinary run, with the same books as everything else.
A third, **`/api/v1/ws/events`**, only listens. It is how an open console
[keeps up with changes made elsewhere](console.md#changes-made-elsewhere):
authenticated the same way, with the organization in `?organization_id=`, it
sends one JSON frame per successful public write in that organization —
`resource`, `id`, `action` (`created`, `updated` or `deleted`), `surface`
(`console`, `api_key`, `mcp` or `assistant`) and who made it — and only for rows
the caller may read. Changes nobody wrote arrive the same way: `run` for a run
moving through its states, and `system` for the platform's own scheduled work,
such as a knowledge gaps grouping pass.
## Voice calls
A [voice call](voice.md) has a socket of its own, because a call is not a turn
loop: **`/api/v1/ws/voice`** for a member, and
**`/api/v1/embed/{public_key}/voice/ws`** for a visitor on a widget or a hosted
page ([admission](channels.md#the-call-socket)). Both speak the same frames,
defined in `backend/app/schemas/voice.py`.
The console socket authenticates as `/ws/agent` does: the token as a
`Sec-WebSocket-Protocol` entry `access_token.<JWT>` (or the same-origin cookie),
never in the query string, and the organization as `?organization_id=`. It
closes with `4001` without a valid session and `4003` when the caller is not a
member or authenticated with an organization API key: a call is a person at the
microphone, as a chat turn is a person at the keyboard.
The embed socket closes with `4029` past a rate limit and `4003` for any
refusal about the embed. Every heartbeat re-checks the session, so a call does
not outlive a sign-out.
**The client sends** JSON frames, and over the relay binary audio too:
| Frame | Fields | When |
|---|---|---|
| `start` | `agent_id`, `conversation_id`, `sdp_offer` (WebRTC), `resume_of` and `resume_token` (a reconnect), `context` (an embed's variables) | First, within 10 seconds |
| `approval` | `id`, `decision` (`approve` or `deny`) | Deciding an approval the call is waiting on |
| `mute` | `muted` | The relay: the microphone muted |
| `played` | `bytes` | The relay: how much of the agent's audio has been played |
| `end` | - | Hanging up |
| *binary* | 16-bit PCM at the model's input rate | The relay: the caller's audio |
**The server sends:**
| Frame | Fields | Means |
|---|---|---|
| `answer` | `sdp` | WebRTC: the provider's answer for the browser's peer connection |
| `ready` | `call_id`, `run_id`, `conversation_id`, `transport`, `input_sample_rate`, `output_sample_rate`, `cap_seconds`, `resume_token` | The call is set up. `resume_token` is sent only here, to this caller; a reconnect sends it back |
| `state` | `state`: `connecting`, `listening`, `user_speaking`, `thinking`, `agent_speaking`, `interrupted`, `tool_running`, `awaiting_approval` | Who is talking, or what the agent is doing |
| `caption` | `role`, `turn_id`, `part`, and one of `delta` (append), `text` (replace with the screened text so far) or `final` | A live caption |
| `turn_saved` | `message` | A finished turn, as the message it was saved as; it replaces the captions carrying its `turn_id` |
| `tool` | `name`, `status` (`running` or `done`) | A tool the agent is running |
| `approval_required` | `id`, `tool`, `args`, `said`, `timeout_seconds` | An approval for the person on the call: the exact arguments, and what they were heard to say |
| `approval_unavailable` | `tool`, `args` | A tool call this caller cannot approve by voice |
| `approval_settled` | `id`, `outcome` (`approved`, `denied` or `expired`) | How an approval ended |
| `notice` | `kind`, `message` (optional) | Something to say in words: a guardrail hit, a limit being reached |
| `limit` | `seconds_left` | The call's time is nearly up |
| `clear_playback` | - | The relay: drop the agent's queued audio |
| `ended` | `reason`, `message` | The call is over |
| *binary* | 16-bit PCM at the model's output rate | The relay: the agent's audio |
**A visitor on a widget or a hosted page is told less.** `ready` leaves out
`run_id`; its `cap_seconds` is the call's limit - the agent's longest call, or
what is left of a call being resumed. The tool frames follow the
embed's `show_tool_steps` and `show_tool_results` switches, as a typed reply's
do. Without tool steps no `tool` frame is sent and `tool_running` is sent as
`thinking`. Without tool results, `approval_unavailable` is sent with empty
`args`. A visitor's `turn_saved` carries only the message's `id`, `role`,
`content`, `interrupted` and `turn_id`, whatever the switches: never its run,
agent or version ids, its cost or its tool calls.
**A reconnect proves it holds the call.** A `start` with `resume_of` must also
carry the `resume_token` from that connection's `ready`. Without it, or with a
wrong one, the reconnect is refused as `resume_refused` and the call it names
is not touched. The server keeps only a hash of the token. A call already made
of 20 connections is not resumed again: its next reconnect is refused as
`resume_refused` too.
A socket that sends no `start` within 10 seconds gets `ended` with `no_start`.
A call that will not start answers with `ended` and a stable `reason`:
`no_agent`, `forbidden`, `not_configured`, `bad_offer`, `data_channel`,
`resume_refused`, `call_limit`, `not_priced`, `budget`, `busy` or `line_busy`,
with a `message` safe to show. `busy` is said only after a call the same person
hung up a moment ago has had up to four seconds to finish ending. A start that
fails for any other reason answers `ended` with `error`.
A visitor refused as `not_configured`, `not_priced` or `budget`, or a visitor's
call that ends as `budget`, gets `unavailable` instead,
with the message "Calls are not available right now.": the code and the full
message are the agent's operator's to know. A call into an archived
conversation is refused as `forbidden`, as a typed message into one is refused.
A call that ran ends with `user`, `time_limit`, `budget`, `disconnected`,
`revoked`, `guardrail`, `shutdown` or `error`; `time_limit` when the agent's
longest call was reached. A visitor's call ends as `revoked` when its embed is
paused, has its calls
switched off or is pointed at another agent. No frame ever carries a provider
credential.
Five HTTP routes go with it:
| Route | Gate | Answers |
|---|---|---|
| `GET /api/v1/agents/{agent_id}/voice` | `agents:run` on that agent, a grant included; `404` otherwise | Whether a call would start, why not (`no_voice`, `not_offered` or `no_key`), the transport and the longest call |
| `POST /api/v1/agents/{agent_id}/voice/preview` | `agents:edit` on that agent, a grant included; `404` otherwise | A draft Voice section's voice saying one fixed sentence, as `audio/wav`. Cached per voice and language; `RATE_LIMIT_VOICE_PREVIEW_PER_MINUTE` per person |
| `GET /api/v1/providers/realtime-models` | `agents:view` | The realtime catalog the Voice section picks from |
| `GET /api/v1/voice/usage?start=…&end=…` | `runs:view` | Call seconds and calls for calls started at or after `start` and before `end`, by agent (a deleted agent's calls as one row with no id or name). An `end` not after `start` is refused with `422` |
| `GET /api/v1/runs?modality=voice` | `runs:view` | Calls only (`text` for typed runs only); a call that reconnected is one row carrying `voice_chain`, every connection summed |
An approval that belongs to a live call is decided in the call:
`POST /api/v1/approvals/{id}` refuses it with `400`.
## Errors
One envelope, everywhere:
```json
{
"error": {
"code": "NOT_FOUND",
"message": "Agent not found",
"details": { "agent_id": "..." }
}
}
```
`details` carries values rather than rows, so it names the field that explains a
refusal and never a database record. When a refusal is about something the
caller submitted, `details.fields` is a list of `{field, message}` — which is
what lets a form mark the input instead of showing a sentence somebody has to
re-scan the page for.
A `401` carries `WWW-Authenticate: Bearer`. A cross-tenant read answers `404`,
not `403`, for the reason above.
**A run that started and then failed names itself.** `POST /agents/{id}/run`
answers `502` with `MODEL_CALL_FAILED` when the model provider refused or failed
the request - every model of a fallback chain included - and `500` with
`RUN_FAILED` for any other failure once the run had begun. Both carry
`details.run_id` and `details.status`, and `details.conversation_id` when the run
wrote to one, so a retry continues that thread instead of opening another; and `message` says why
in the platform's words rather than in the provider's raw response. The run is
recorded as failed with what it spent, so `GET /api/v1/runs/{run_id}` reads it;
sending the request again starts a new run.
## Retries, idempotency and revisions
v1 has no revision preconditions: no `ETag`, no `If-Match`. A write is applied
when it arrives, and the last one wins. `Idempotency-Key` is honoured on one
route, the asynchronous run.
- **`GET`, `PUT` and `DELETE` are safe to repeat.** Reading twice reads twice;
deleting what is already gone answers `404`, which a retry can treat as done.
- **`PATCH` is safe to repeat with the same body**, and only then: two clients
patching one field overwrite each other rather than conflicting.
- **`POST` is not.** A repeated `POST /agents/{id}/run` starts a second run and
is billed as one; a repeated create makes a second row. After a timeout or a
dropped connection, look before you retry: `GET /api/v1/runs?agent_id=…&surface=api`
lists the runs newest first, and a list read finds a row the first attempt did
create.
- **An asynchronous run is, with an `Idempotency-Key`.** Send
`Prefer: respond-async` and a key of your choosing, at most 255 characters,
and a retry with the same key and the same body answers the same run request,
in whatever state it is in. The same key with a different body is a `409`,
never a second run. Keys are per organization. A synchronous call carrying
the header is refused with a `400` rather than letting a caller believe a
retry is safe.
- **`429` is the one refusal to retry**, after the `Retry-After` it carries. Every
other `4xx` is final for that request.
## Conventions
| | |
|---|---|
| Prefix | `/api/v1` |
| Create | `POST`, `201` |
| Partial update | `PATCH` |
| Delete | `DELETE`, `204`, no body |
| Pagination | `skip` (≥ 0) and `limit` (1–100) query parameters; list responses carry `items` and `total` |
| Paths | kebab-case |
## Stability, honestly
**The public API has a written compatibility promise; the rest of `/api/v1` does
not.** The routes an API key may call are listed in their own OpenAPI document at
**`/api/v1/public/openapi.json`**, served in every environment. Within v1 a change
to one of them is additive — a new route, a new optional field, a new response
field or enum value — and a client must ignore response fields it does not know.
Removing or renaming something there happens only after it has been marked
`deprecated` in that document for at least 90 days and listed in the
[release notes](release-notes.md); a change that cannot be made that way goes into
`/api/v2`, beside v1.
The console reads the same document as a page: **Settings → API → API reference** lists every endpoint with its parameters, body, responses and a ready `curl` call.
The first rename under that rule is apps (#2071): what were artifacts are served at
`/api/v1/apps` and `/api/v1/public/apps`, and the old `/api/v1/artifacts` and
`/api/v1/public/artifacts` paths answer the same way, marked `deprecated`, until
the 90 days are up. Permission names (`artifacts:view`, `artifacts:edit`), the tool
names and the addresses a published page is served from keep their names.
The console's own routes carry no such promise and change with the console; a key
cannot call them. There is no client library yet.
What the public API does not do yet, so a client is not built on it:
- **Virtual Tables** are not on it; reading and writing a table with a key is
[#1795](https://github.com/vstorm-co/agenticos/issues/1795).
- **No streaming for a key.** The chat socket takes a person's session only.
- **No idempotency keys and no revisions**, as above.
The [agent spec](reference/spec.md) carries its own promise: it is versioned and
only moves forward.
## Recap
- **`/docs`** on the deployment is the generated reference; it is off in
production by design.
- Three ways in: **an organization API key, a JWT, or the console's cookie**.
- A key carries its issuer's authority **narrowed to its permissions and to the
issuer's current role**, is shown once, and works on the public API only.
- **A key acts in its own organization; a session reads `X-Organization-Id`**
and falls back to the default, personal or only organization without it. The
wrong one looks like a missing resource.
- Running an agent over HTTP is the **same runner** — budget, approval and audit
all apply.
- A script gets its own key **without a browser** — sign in, issue, revoke — and
`examples/public-api.sh` runs that round trip.
- **Only `429` is worth retrying**; a `POST` is not idempotent, so look before
repeating one - except an asynchronous run sent with an `Idempotency-Key`.
- **`Prefer: respond-async`** turns a run into a `202` and a run request to
poll, so a long run does not hold a connection open.
- **The public API is in `/api/v1/public/openapi.json`** and changes only
additively within v1, with 90 days' deprecation; there is no SDK yet.
Skills whose load requires approval must be approved again in each turn; historical loads never bypass that gate.