API
Autonomy and actions
What a run is waiting for, how to answer it, and the stored policies that decide what it never has to ask about in the first place.
The project is narrowed whether you ask or not
An agent slug is unique within a project rather than within a tenant, so without narrowing one tenant's worker in dev and in prod would share an inbox — and a credential scoped to one could decide the other's confirmations. A read narrows to what your credential may see; a write must NAME its project, because a policy lands in exactly one scope and the target slug cannot say which.
Acting for one of your own users is user: "eu:<id>"
Their credential is a ticket, so they cannot reach any of this themselves — your backend is the only thing that can, and it is the same authority you already hold over their wallet. Omit it and you act as yourself. Naming a COLLEAGUE needs admin, and "*" is refused outright: that is the tenant scope's sentinel, which is reached with scope: "tenant" instead so there is one admin-gated door rather than two.
An action in another tenant is 404, not 403
Its existence is itself information. The same reasoning applies to a project that is not yours.
A 503 here does not always mean the store is down
On the inbox, the labels and the policy routes, rt-service reports every refusal from the actions store as could not reach the actions store — including the store's own refusal of an unknown kind or a malformed policy. Check what you sent before you retry.
The samples use $API and $TOKEN, which the API overview sets.
List pending actions
GET/v1/actionsrt-service
What is waiting for a decision — yours, one of your users', or, for an admin asking for the tenant scope, everyone's.
kind defaults to confirmations, and the other kinds are real
This store also holds long-running tool calls, whose answer is a system's result rather than a person's judgment, and approvals raised for somebody outside the conversation. Pass kind=tool to list the work your own backend owes.
An automatic decision is recorded as an action too
Precisely so it can be read back beside the asked-for ones — automatic=true is what a policy decided without a person, and automatic=false is what somebody chose. It is a filter over the same rows either way, which is what makes "what has my agent been doing unattended" answerable.
# Your own pending confirmations. curl "$API/v1/actions?status=pending" \ -H "Authorization: Bearer $TOKEN" # One of your users' cards. curl "$API/v1/actions?user=eu:ada&project=agents" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| status | query | optional | pending by default. Also approved, rejected, abandoned, answered (a tool call given its result), a comma-separated list of them, or all. An unknown value matches nothing. |
| kind | query | optional | confirmation by default. Also tool, approval, and all for every kind. Anything else is refused by the store and reaches you as a 503. |
| label | query | optional | Repeatable. The categories your manifest declared for the tool. |
| target | query | optional | One target's cards only. |
| session_id | query | optional | One conversation's only. |
| scope | query | optional | mine by default. tenant is everyone's, and needs admin. |
| user | query | optional | Act for somebody else — eu:<id> for one of your own users. |
| project | query | optional | Narrow to one project. A project-scoped credential naming none is narrowed to its own. |
| automatic | query | optional | true for what a policy decided, false for what a person did. |
| limit | query | optional | 1–500, default 100. Outside that range is a 422, not clamped. |
| offset | query | optional | Default 0. |
Responses
| Status | Meaning |
|---|---|
| 200 | {actions, total, limit, offset}. |
| 400 | scope=tenant together with user, or user=*. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket; scope=tenant or a colleague's user without admin; or a project outside the credential's scope. |
| 422 | A limit or offset out of range, or an automatic that is not a boolean. |
| 503 | The actions store is unreachable — or refused the query, as it does an unknown kind. |
Response body
{
"actions": [
{
"action_id": "act_01HQ2K8ZJ7",
"kind": "confirmation",
"status": "pending",
"batch_id": "bat_01HQ2K8ZJ6",
"batch_size": 1,
"batch_pending": 1,
"target_slug": "hotel-concierge",
"target_version": "1.4.0",
"tenant_id": "acme",
"project_id": "agents",
"user_id": "eu:ada",
"tool": "issue_refund",
"labels": ["money"],
"tool_args": {"room": 204, "amount": 2500, "reason": "disputed stay"},
"hint": "Refund $25.00 to room 204?",
"payload": null,
"invocation_id": "f27bfbb4a1c34e...",
"session_id": "sess-ada-1",
"automatic": false,
"auto_tier": null,
"auto_reason": "",
"deadline": null,
"decided_by": null,
"requested_at": "2026-10-17T09:14:02+00:00",
"decided_at": null
}
],
"total": 1,
"limit": 100,
"offset": 0
}List categories with something pending
GET/v1/actions/labelsrt-service
The filter chips, with a count each.
Scoped exactly as the inbox is
Through the same helper, deliberately: a count beside a filter that disagrees with the list the filter produces is worse than no count at all.
curl "$API/v1/actions/labels?user=eu:ada" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| scope | query | optional | mine by default. |
| user | query | optional | eu:<id> for one of your users. |
| project | query | optional | Narrow to one project. |
Responses
| Status | Meaning |
|---|---|
| 200 | A list of {label, pending} — how many actions carrying each label are still pending. |
| 400 | scope=tenant together with user, or user=*. |
| 403 | A scope, subject or project the credential may not read. |
| 503 | The actions store is unreachable, or refused the query. |
Response body
[
{"label": "money", "pending": 3},
{"label": "guest-data", "pending": 1}
]Approve or reject a tool confirmation
POST/v1/actions/{action_id}/decidert-service
One card, answered. The run resumes when the turn's last outstanding confirmation is decided — not necessarily on this call.
A turn's answers travel as ONE batch, and this is why
The agent framework resolves EVERY outstanding confirmation with the next message. So a turn that raised three cannot publish three decisions separately — doing so would silently abandon the other two while reporting the run finished. Decisions are accumulated and published once the last member is decided, and the response says which happened.
Answering twice runs the tool twice
There is no idempotency of the framework's own, and delivery can redeliver a resume. What protects you is the conditional claim here — one decision per action — which is why a second answer is a 409 rather than a second run. Treat the 409 as success.
A late approval of an abandoned card still would have run the tool
Measured: a card charged after the conversation had moved on. So a batch with a later user turn is refused and recorded abandoned — which is why that status can follow an approval. It means nothing ran.
curl -X POST "$API/v1/actions/$ACTION_ID/decide" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"approved": true, "note": "verified with the guest"}'Body
| Field | Type | Description | |
|---|---|---|---|
| approved | boolean | required | The decision. false rejects, and the tool is told so rather than failing. |
| note | string | optional | Up to 1000 characters, recorded on the audit row. |
| payload | any | optional | Whatever the tool asked the human to supply, if it asked for anything. |
| approve_all | boolean | optional | Also stop asking about this tool. A separate decision from approving this call, so it is a separate flag — and the standing grant is recorded only once this decision has actually gone through. Clamped to the manifest's ceiling like any stored policy. |
| user | string | optional | Decide on behalf of one of your own users — eu:<id>. |
Responses
| Status | Meaning |
|---|---|
| 200 | Recorded. The body says whether the run was resumed or is still waiting on siblings. |
| 400 | user was *. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket; neither the requester nor an admin; or a colleague named in user without admin. |
| 404 | Unknown action — or one in another tenant, or in a project the credential is not scoped to. |
| 409 | Already decided. Not a failure: somebody, or another delivery of the same answer, got there first. |
| 422 | No approved, or a note over 1000 characters. Unknown fields are ignored, not refused. |
| 502 | The decision could not be queued, so it was undone. Decide again. |
| 503 | The action could not be looked up. Nothing was recorded. |
Response body
{
"action_id": "act_01HQ2K8ZJ7",
"status": "approved",
"batch_id": "bat_01HQ2K8ZJ6",
"batch_complete": true,
"published": true,
"rule_id": null
}Answer an approval
POST/v1/approvals/{action_id}/decidert-service
An approval is asked of somebody who is not in the conversation, with a form. Answering it resumes the run with the decision and the fields.
Who may answer comes from the TOOL's declaration, not from a policy
A tool declaring asks="admin" needs the tenant's admin role and cannot be answered on an end user's behalf — holding admin so you can mint your customers' tickets is not the same as deciding as the tenant. asks="end_user" is the row's owner, or a project credential naming them, which is how you build your own customers' approval queue.
Deciding a confirmation here is a 409, naming the right route
Confirmations and approvals share one table and only kind tells them apart, so crossing the two routes refuses explicitly rather than doing something surprising.
A rejection is held to the same form as an approval
Coercion runs BEFORE the service looks at the decision, so an approval declaring a required field with no default cannot be REJECTED without supplying it — the refusal reads "'Cost center' is required" on a call that was declining to incur one. Give a field a default if a denial should not need it.
The answers do not come back on the action row
They travel to the agent in the resume, and the stored action keeps the REQUEST — so reading the action back afterwards shows what was asked rather than what was answered.
curl -X POST "$API/v1/approvals/$ACTION_ID/decide" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"approved": true, "answers": {"cost_centre": "4412"}}'Body
| Field | Type | Description | |
|---|---|---|---|
| approved | boolean | required | The decision. |
| answers | object | optional | The form the tool declared, filled in and keyed by field name. Coerced against the declaration, so a form's strings arrive as the types the callback asked for — and an UNKNOWN key is refused with a 400 rather than dropped. |
| note | string | optional | Recorded on the audit row. |
| user | string | optional | eu:<id>, for an asks="end_user" approval. Refused for an admin one. |
Responses
| Status | Meaning |
|---|---|
| 200 | Answered. {action_id, status, published} — published is whether the run was resumed by this answer. |
| 400 | The answers do not satisfy the form the tool declared — a required field missing, a value of the wrong type, or a key it does not declare. The message names the field as the form labels it. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket; an admin approval decided without admin, or on an end user's behalf; or somebody else's to give. |
| 404 | Unknown approval, or one in a project the credential is not scoped to. |
| 409 | Not an approval, or already answered. |
| 422 | A body that does not match the model: no approved, a long note, or an unknown top-level field. |
| 502 | The answer could not be queued, so it was undone. |
| 503 | The approval could not be looked up. |
Answer a long-running tool call
POST/v1/tools/{call_id}/resultrt-service
Not a judgment — a result. Your own system finished the work and is handing back what it produced.
This is the third kind on the same table
List them with kind=tool on the inbox route, or with elan.tools.pending(). They are the work YOUR backend owes the run, rather than a decision a person owes it, which is why the answer is a value and not an approval.
The run's owner or an admin, and there is no user
Unlike deciding a confirmation, this route takes no user to act for one of your end users. A call raised in a run you started with end_user_id belongs to that user, so answering it needs a tenant admin.
curl -X POST "$API/v1/tools/$CALL_ID/result" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"result": {"status": "shipped", "tracking": "AB1234"}}'Body
| Field | Type | Description | |
|---|---|---|---|
| result | object | optional | What the tool returns to the model, as a JSON object. Sent exactly when error is not: with both, error wins and result is dropped; with neither, the tool returns {}. |
| error | string | optional | Instead of a result, up to 2000 characters. The agent is told the tool failed and can explain. |
Responses
| Status | Meaning |
|---|---|
| 200 | Recorded. {call_id, status, batch_id, batch_complete, published} — the run resumes when the turn's last open call or decision is answered. |
| 401 | No credential, or one that does not verify. |
| 403 | A ticket, or neither the run's owner nor an admin. |
| 404 | Unknown call, or one in a project the credential is not scoped to. |
| 409 | Already answered, or not a tool call — a confirmation is decided instead. |
| 422 | A result that is not an object, an error over 2000 characters, or an unknown field. |
| 502 | The result could not be queued, so it was undone. Send it again. |
| 503 | The call could not be looked up. |
Read the stored policies
GET/v1/autonomy/policiesrt-service
Both scopes that bear on a subject: their own rules, and their tenant's cap.
They come back in one flat list, and the SDK splits them
Because a caller that did not split them would read a tenant cap and write it back as their own. Each row carries its scope; the SDK exposes mine and tenant separately for that reason.
A tool nobody has touched is ABSENT, not defaulted
There is no row, and that means "no opinion" rather than "as the agent ships". Do not synthesize one from the manifest: if an untouched policy voted, every user who had never chosen anything would cancel their tenant's grant.
curl "$API/v1/autonomy/policies?target=hotel-concierge&user=eu:ada" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| target | query | optional | One target's policies only. |
| project | query | optional | Narrow to one project. |
| user | query | optional | Whose policies — eu:<id> for one of your own users. Defaults to you. |
Responses
| Status | Meaning |
|---|---|
| 200 | A list of policy rows, both scopes. |
| 400 | user=* — the tenant scope is not a user. |
| 403 | You may not read that subject's policies, or that project's. |
| 503 | The actions store is unreachable, or refused the query. |
Response body
[
{
"policy_id": "pol_8c1f0e2a9b7d4c6e5f3a1b2c",
"tenant_id": "acme",
"project_id": "agents",
"user_id": "eu:ada",
"scope": "user",
"target_slug": "hotel-concierge",
"tool": "issue_refund",
"policy": {
"labels": [],
"tier": "t1",
"rules": [
{"when": {"field": "amount", "op": "<=", "value": 5000},
"outcome": "allow"}
]
},
"source": "setting",
"edited_by": "u_01HQ2K8ZJ1",
"updated_at": "2026-10-17T09:20:00+00:00",
"revoked_at": null,
"live": true
}
]Store a policy
PUT/v1/autonomy/policiesrt-service
Replaces one tool's stored policy for one scope. Read the existing one first.
This REPLACES; it is not a delta
Editing a single threshold rewrites that scope's whole policy for the tool, so anything calling this must read the existing policy and write it back with the change substituted. Building a fresh policy silently drops every other condition that scope carried. The SDK's rule() and tier() do that read-modify-write for you, which is the reason to prefer them over this route.
Two things are stripped on the way in and re-imposed from the manifest
The ceiling, so nobody can raise the limit meant to bound them; and declared REFUSALS, so nobody can drop a prohibition the agent's author wrote. Stripped on write AND ignored on read, because a row is data from a database.
A write must name its project
A read narrows to what you may see; a write lands in exactly one scope, and target_slug cannot say which — an agent slug is unique within a project, not a tenant. Leave project empty and the policy lands in the tenant's unattributed scope, which every project's agent of that slug reads; a project-scoped credential is refused that with 403 and may only name its own.
The SDKs cannot name a project here yet
Python's rule(), tier() and set() send no project, so with an unscoped credential they write the unattributed scope and with a project-scoped one they are refused with 403. The Node client's writes send target_slug and tool as query parameters, which this route does not read, so they are refused with 422. Call the route directly when the project matters.
curl -X PUT "$API/v1/autonomy/policies" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"target_slug": "hotel-concierge",
"tool": "issue_refund",
"project": "agents",
"user": "eu:ada",
"policy": {
"tier": "t1",
"rules": [
{"when": {"field": "amount", "op": "<=", "value": 5000},
"outcome": "allow"}
]
}
}'Body
| Field | Type | Description | |
|---|---|---|---|
| target_slug | string | required | The target this governs. |
| tool | string | required | The tool name, as the manifest keys it. |
| policy | object | required | {tier, rules}. A rule is {when: {field, op, value}, outcome}; when: null matches every call. outcome is refuse, ask or allow. |
| scope | string | optional | user by default. tenant writes the cap and needs admin. |
| user | string | optional | Whose policy. eu:<id> for one of yours. Ignored for the tenant scope. |
| project | string | optional | Which project's target. Empty is the unattributed scope every project reads; a project-scoped credential must name its own. |
| reason | string | optional | Recorded on the audit row. |
Responses
| Status | Meaning |
|---|---|
| 200 | The stored policy, as it was saved — read it back to see what the ceiling clamped. |
| 400 | user=*; the tenant cap is scope: "tenant". |
| 403 | Only an admin may set the tenant's policy or name a colleague; or a project the credential is not scoped to, an empty one included. |
| 404 | An unknown target — only on a deployment that keeps an allowlist of targets. |
| 422 | A body that does not match the model: target_slug, tool or policy missing, or a scope other than user or tenant. |
| 503 | The actions store is unreachable — or refused the policy, as it does an operator, an outcome or a tier the resolver does not know. |
Give a tool back its shipped behavior
POST/v1/autonomy/policies/{policy_id}/resetrt-service
Revokes one stored policy, so the scope returns to having no opinion and the manifest decides again.
This is not "set it to the default"
It revokes the row: kept for the audit trail, with revoked_at set and live false, and skipped by the resolver. The difference matters: a scope with no live row has no opinion and does not vote, whereas a row holding the manifest's own values WOULD vote — and would cancel the tenant's grant for everybody who had ever touched and reverted a setting.
curl -X POST "$API/v1/autonomy/policies/$POLICY_ID/reset?user=eu:ada" \ -H "Authorization: Bearer $TOKEN"
Body
| Field | Type | Description | |
|---|---|---|---|
| project | query | optional | The project the policy is in. |
| user | query | optional | Whose policy — eu:<id>. The policy is looked up in the SUBJECT's rows, not yours. |
Responses
| Status | Meaning |
|---|---|
| 200 | The policy row, now revoked. |
| 400 | user=*. |
| 403 | Only an admin may reset the tenant's policy or a colleague's; or a project the credential is not scoped to. |
| 404 | Unknown policy for that subject and project — or one already reset, because only live policies are looked up. |
| 503 | The actions store is unreachable. |