SDK
Approvals and autonomy
A run can stop to wait for a person or for a system. These namespaces are how your backend lists what is waiting, answers it, and sets the policy that decides what is asked in the first place. They wrap the autonomy and actions API.
Three kinds of waiting
One store holds all three, and they share its batching and its one-answer-only guarantee. They are split into namespaces because they are answered by different things, and an inbox mixing them is one nobody can act on.
| Kind | Owed by | List with | Answer with |
|---|---|---|---|
| confirmation | The person in the conversation, before a tool runs. | actions.list(), actions.batches() | actions.decide_batch(), actions.decide() |
| approval | Somebody outside the conversation, with a form. | approvals.pending() | approvals.decide(), approve(), reject() |
| tool | An outside system, through your backend. | tools.pending() | tools.submit(), tools.fail() |
A workflow that pauses for input creates none of these; it is answered with runtime.resume() — see what a paused run looks like. runs.waiting() is the one call that lists every paused run, whatever it waits for.
Confirmations, and why they come in batches
One model turn can ask to confirm several tool calls, and the next message to the run resolves all of them. Answer one of two and the other is silently abandoned; send a plain message instead and all of them are. So a turn's answers travel together, and only the last one to be decided resumes the run.
from elan_sdk import Elan
elan = Elan.from_env()
# One of your end users' turns. Their cards are recorded against eu:<id>.
for batch in elan.actions.batches(session_id="sess-ada-1", user="eu:ada"):
for action in batch.actions:
print(action.tool, action.tool_args, action.hint)
outcome = elan.actions.decide_batch(batch, approved=True, user="eu:ada")
if not outcome.published:
print("recorded; the turn still has members open")
for decision in outcome.abandoned:
print(decision.action_id, "was approved and did NOT run")batches()groups pending actions by the turn that raised them, reading every page so a turn is never split, anddecide_batch()(decideBatch) answers every member in order. Use them unless you know a turn raised exactly one confirmation.- Nothing ran until
publishedis true. ADecisionfor an earlier member of a batch is recorded and resumes nothing; a 200 is not “the tool ran”. abandonedcan follow an approval. If the conversation moved on before the batch completed, the engine refuses it rather than run a tool somebody no longer expects, and records each member as abandoned.approve_all(approveAll) also writes a standing grant so the tool stops asking. It is recorded against the run's owner, not whoever decided, and its id comes back asrule_id.- Who may decide: the run's owner, an admin, or a credential that names the owner with
user="eu:<id>"when the run is one of your end users'. Deciding one that was already decided is aConflict.
Listing
actions.list() returns an ActionPage of pending confirmations by default. Its filters: status, kind (confirmation, tool, approval or all), labels (any of them), target, session_id, automatic (unset for every row, false for what a person decided, true for what policy decided), and limit up to 500 with offset against total. labels() counts what is pending per label.
- Whose: your own by default.
user="eu:<id>"is one of your end users and needs no admin role, because their own credential is a ticket and cannot list anything.scope="tenant"is everyone's and needs an admin; sending both is aBadRequest. - Which project:
projectnarrows rather than authorizes. It defaults to the client's;""means every project the credential may see. A credential issued for one project is narrowed to it regardless, and naming a sibling is aForbidden. actions.get(action_id)has no route of its own: it searches a listing of up to 500 rows of every kind and status, and returns nothing when the action is not among them.
Approvals
An approval is owed by a person outside the conversation — a manager, a finance team — through a form the tool declared. It is raised as a long-running call, so it survives the conversation carrying on. Approvals covers declaring one.
for request in elan.approvals.pending(user="eu:ada"):
print(request.title, request.body, request.asks)
for field in request.fields:
print(" ", field.name, field.type, "required" if field.required else "")
if request.asks == "end_user":
decision = elan.approvals.approve(request.action_id,
answers={"cost_centre": "CC-3317"},
user="eu:ada")
print(decision.status, decision.published)- Read
asksfirst.adminneeds the tenant's admin role and cannot be answered on an end user's behalf;end_useris answered by the run's owner, or by a credential naming them withuser.bodyis what the card says;fieldsis the form. answersare checked against the form before anything is published and coerced to the declared types. An unknown key is refused, not dropped.- A rejection must satisfy the form too. A required field with no default has to be answered even to say no, so give the fields defaults if a refusal should not need them. The answers travel to the agent with the resume; reading the action back shows what was asked, not what was answered.
Long-running tools
A tool that hands work to something outside the platform returns nothing, and the run waits for your backend to say how it went. The deadline is the agent's limits.tool_deadline_s (a day unless it says otherwise); see Building an agent for writing one. There are no webhooks, so your backend polls.
for call in elan.tools.pending():
if call.expired:
continue # the deadline passed; it was answered for you
receipt = registrar.poll(call.args["company"])
if receipt is None:
continue # still outstanding; try again next poll
if receipt.refused:
elan.tools.fail(call.call_id, error=receipt.reason)
continue
outcome = elan.tools.submit(call.call_id, result={"receipt": receipt.id})
if not outcome.published:
print(call.call_id, "recorded; its turn has other members open")submit()'sresultbecomes the tool's return value, verbatim, and the agent carries on from it.fail()delivers{"error": …}instead, so the agent can tell its user what went wrong; the reason may be up to 2,000 characters, and Python refuses an empty one before sending.- A call is answered once. A second answer, or one after the deadline — when the platform has already answered with a timeout error — is a
Conflict.expiredtells you before you do the work. publishedsays whether the run resumed. A turn that opened several calls, or mixed them with confirmations, resumes when its last member is answered.pending()lists only calls in your own runs, and neither client lets it name an end user. For a run started withend_user_id, list the calls withactions.list(kind="tool", user="eu:<id>")— the row'saction_idis the call id — and answer them with a tenant admin credential: only the run's owner or an admin may submit, and the route takes nouser.
Autonomy: what gets asked
Every declared tool call resolves to refuse, ask or allow, by tier and by rules over its arguments. Autonomy and action policy explains how they combine. autonomy reads and writes the stored policies.
scopes = elan.autonomy.policies(target="hotel-concierge", user="eu:ada")
mine = scopes.for_tool("hotel-concierge", "refund") # Ada's own, or None
cap = scopes.for_tool("hotel-concierge", "refund", scope="tenant") # the tenant's cap
print(mine.policy if mine else "as the agent ships", cap)
# Read, modify, write back: this keeps every other condition the scope had.
# From a project-scoped service user this write is refused today; see below.
elan.autonomy.rule(target_slug="hotel-concierge", tool="refund",
when={"field": "amount", "op": "<=", "value": 50},
outcome="allow", user="eu:ada",
reason="small refunds need no approval")| Method | What it does |
|---|---|
| policies(*, target, user) | Both scopes that bear on a subject, split into mine and tenant, with subject saying whose mine is. for_tool() (forTool) picks one tool's, or nothing. |
| rule(*, target_slug, tool, when, outcome, scope, user, reason) | Sets one condition and keeps the rest: reads the stored policy, replaces the rule with the same when or appends it, and writes it all back. |
| tier(*, target_slug, tool, tier, scope, user, reason) | The same read-modify-write for the tier: t0 refuses, t1 asks, t2 runs unattended. |
| set(*, target_slug, tool, policy, scope, user, …) | The raw replace. It drops every condition the scope had, which is why the two above exist. |
| reset(policy_id, *, user, revoked_by) | Back to how the agent ships — no stored opinion at all, which is not the same as storing the default. |
- A stored policy replaces the tool's rules; it is not a delta. Building a fresh one and saving it silently drops every other condition. Use
rule()andtier(). whenis{field, op, value}withopone of==,!=,<,<=,>,>=,containsorin; nowhenmatches every call.outcomeisallow,askorrefuse.- What comes back is the stored row, not an echo. The ceiling and the agent's declared refusals are stripped on the way in and re-imposed from the manifest, so nobody can raise the limit meant to bound them.
- Whose: your own by default;
user="eu:<id>"for one of your end users, with no admin role needed; a colleague needs an admin;scope="tenant"is the cap over everybody and needs an admin."*"is refused as a user.
The policy writes do not yet work from a project-scoped service user
Reading policies works in both clients. Writing them — rule(), tier() and set() — currently does not reach the route the way it needs to. The Python client sends no project, so a service user issued for one project is refused with Forbidden, and a tenant-wide credential writes the unattributed policy that every project's agent of that name reads. The TypeScript client sends the target, tool, scope and project as query parameters while the route reads them from the body, so its writes are refused as a BadRequest. Until that is fixed, write a policy with a direct PUT /v1/autonomy/policies that names project in its body — see the autonomy reference — and keep the read-modify-write yourself: read with policies(), change one rule, send the whole policy back. reset() is unaffected.