Open the console

Guides

Approvals

When somebody who is not in the conversation has to decide before your agent acts — a manager signing off a refund, an admin publishing a report. You declare the question, the form and what happens on each answer; the platform files it, waits for it, and resumes the run with the decision.

from agentkit_core.approvals import ApprovalField, ApprovalTool
from agentkit_core.manifest import InputOption
from google.adk.agents import BaseAgent, LlmAgent


def publish_now(request):              # request: ApprovalRequest
    url = store.publish(request.tool_args["reason"],
                        visibility=request.answers["visibility"])
    return {"published": url}          # becomes the tool's result


def record_refusal(request):
    return {"published": False, "why": request.note}


publish = ApprovalTool(
    name="publish_the_logbook",
    description="Publish the logbook. Somebody at the organization decides.",
    asks="admin",
    args={"reason": "Why it should be published"},
    fields=[
        ApprovalField(name="visibility", label="Visibility", type="enum",
                      options=[InputOption(value="public", label="Public"),
                               InputOption(value="internal", label="Internal")],
                      default="public"),
    ],
    on_approval=publish_now,
    on_deny=record_refusal,
)


def build(ctx) -> BaseAgent:
    return LlmAgent(name=ctx.slug, model=ctx.model,
                    instruction="...", tools=[publish.adk])

Hand the model publish.adk, the ADK tool, and keep the ApprovalTool at module scope: the runtime finds it from the built agent when a decision arrives. args maps each argument the model must pass to a description; every one is a required string.

When this, and when a confirmation

The person deciding isUse
in the chata confirmation — tool_context.request_confirmation(hint=…) in the tool, which lets you validate first and word the card yourself. require_confirmation=True also works, and shows ADK's default hint, which is written for a developer.
somewhere elsean ApprovalTool

That is not a style preference. ADK resolves every outstanding confirmation with the next user message. When the decider is the person typing, the gap is seconds and they are not typing during it. When it is somebody else, the end user sits in a live chat where anything they send cancels the request — and no instruction to the model closes that, because they can always type. An approval is a long-running call, which survives a new message.

Who answers: asks

asksWho may answerReached through
end_user (default)The run's owner, or a tenant adminFor a run made on a user's behalf, your own backend, deciding with user="eu:<id>" — any credential for the project may
adminSomebody holding the tenant's admin role, deciding as themselvesThe console's Action policy page in the project, or the API

Acting for a customer is not acting as the tenant. A call that names an on-behalf user is refused on an admin approval with a 403, however much admin the credential holds. Your backend holds admin so it can mint your users' tickets, and that must not become authority to answer the tenant's own questions.

The form

fields is the same model a workflow's launch inputs use: seven types — string, text, number, integer, boolean, enum, date — with options, default, required (on by default) and help. An optional field must have a default. The console renders the form; your callback receives the answers coerced to the declared types.

Answers are typed on the server, before anything is published, so a bad one is refused with a 400 naming the field the approver can see — “'Visibility' must be one of public, internal” — rather than reaching your callback as a surprise. An unknown key is refused rather than dropped.

A rejection has to satisfy the form too

Answers are checked before the decision is looked at, so an approval with a required field and no default cannot be declined without supplying it. Give required fields a default, or make them optional, if declining should be one click.

What the approver is told they are approving

A static body is often not enough. “Publish the logbook” does not say whether that is three entries or three hundred, and the model's own arguments cannot be the answer — those are its account of what it wants, not the record. So describe is handed the conversation's state, read by the platform as the request is filed, and returns the card's body:

def describe_the_logbook(ctx):            # ctx: ApprovalContext
    log = ctx.state.get("sightings") or []
    if not log:
        return "The logbook is EMPTY. There is nothing to publish."
    return f"{len(log)} entries:\n" + "\n".join(
        f"  - {e['count']} x {e['species']}" for e in log)

ApprovalTool(..., describe=describe_the_logbook)

ctx.state is the session state and ctx.tool_args is what the model called with. Newlines survive to the console. title defaults to the tool name made readable, and labels are what the approvals list can be filtered by.

This cannot be done in the tool body. ADK files the request before it calls the body, so anything computed there arrives after the card is already published. A describe that raises falls back to the static body rather than stranding an approval nobody can see.

What the model is told while it waits

The tool returns an initial result at once — ADK's long-running contract — so the model can tell the user what happened instead of leaving them with silence:

{"status": "awaiting_approval", "asks": "admin", "title": "Publish the logbook"}

pending= adds to it — a note about turnaround, a reference, anything the model should be able to mention. It cannot replace status, asks or title: the runtime uses status to tell this initial result from a real decision, and a tool that replaced it would make its approvals unanswerable. Tell the model in its instruction what to say at both moments — when the request is filed, and when the decision comes back as the tool's result.

The callbacks

A confirmation re-runs its tool body with the answer. A long-running tool does not re-run, so the work goes in on_approval and on_deny, which the platform calls when the decision arrives. Either may be a plain or an async function, and what it returns becomes the tool's result — a dict as it is, anything else as {"result": value}.

Both are optional. A tool with neither is a pure gate: the result is {"approved": …, "answers": …}, which is all an agent needs when it only wants permission to carry on.

ApprovalRequest fieldWhat it holds
approvedThe decision.
answersThe form, coerced to the declared types.
tool_argsWhat the model called with — what was approved.
decided_by, noteWho decided, and what they wrote. Set by the platform, never by the caller.
stateThe conversation's session state, read when the decision arrives.
call_id, tool, invocation_id, session_id, tenant_id, project_id, user_id, target_slugWhere it came from.

What a callback cannot reach

Writing to request.state does nothing. It is a snapshot: a state change has to ride on an event, and a callback produces none. If the work needs to be in the conversation, return it — the model sees the return value. A callback has no ToolContext and cannot read artifacts.

If it raises, the model is told, in the tool's result: “publish_the_logbook was approved, but carrying it out failed: …”. The decision is already recorded, so swallowing the error would leave a failure nobody can see. A callback runs once per decision even if the message carrying it is redelivered.

The deadline

Limits(approval_deadline_s=…) in the manifest: a week by default, between a minute and thirty days. A confirmation needs no deadline because the conversation moving on ends it; an approval survives that, so without one a request nobody answers would hold its run open for ever. Past it the run resumes with an error your agent can report — and neither callback runs, because nobody decided anything.

What it costs

The callback runs before the resumed turn reserves anything from the payer's balance, so work done there is done on the strength of the approval, not of the balance. The turn that follows opens a fresh hold against the balance as it is at that moment — a week-old approval resumes against today's balance. See Billing and the ledger.

What the pause is called

A paused run's terminal event carries interrupt_kind, and an approval's is approval — not tool, although both are long-running calls. It means somebody who is not in this conversation is deciding, and the honest thing to tell the end user is that they can carry on. Telling them to submit a tool result names the wrong actor.

A turn that raised several kinds reports the longest-lived one: approval over tool over confirmation over input. So branch on it for copy, and never to decide whether there is a card to show — ask what is pending, because a turn reported as approval may also hold a confirmation the reader must answer themselves.

Answering one

from elan_sdk import AsyncElan


async def review(user: str) -> None:
    async with AsyncElan.from_env() as elan:
        for request in await elan.approvals.pending(user=user):
            print(request.title, request.body, request.fields)
            decision = await elan.approvals.approve(
                request.action_id,
                answers={"visibility": "internal"},
                user=user,
            )
            print(decision.status, decision.published)

That is an end_user approval answered by your backend for eu:ada; an admin one is decided with no user. reject takes the same arguments. published says the run was resumed. A second decision on the same approval answers 409. The routes are in Autonomy and actions and the methods in Approvals and autonomy.

Who sees the answer

The reply lands when nobody is connected — that is the point of the feature, and it is also a delivery problem you have to design for. The event stream is the live path and never the durable one: nothing buffers events for a socket that is not open, and a subscribe replays nothing. An admin approval is answered minutes or days later, by which time the socket that started the run is gone, and the agent's reply is published to a conversation nobody is listening to.

It is not lost. The callback runs, the model replies, and all of it is in the run's record, which GET /v1/runs/{invocation_id} (elan.runs.get) reads even while the run is still going. So a product built on approvals needs one of:

  • Re-read the run when the user comes back, and render anything after the pause.
  • Keep a socket subscribed to the session for as long as the user has that conversation open, so a reply that arrives while they are looking is delivered live.
  • Have the callback tell somebody itself — an email, a push, a row in your own database. It is the only one of the three that works when the end user is not there at all.

On the action itself, status records what was decided and applied records that the runtime acted on it.

Where it does not work

A workflow's FunctionNode holds a plain function, not a tool, so an ApprovalTool in a graph is reachable only from an LlmAgent node. A graph's own human gate stays RequestInput — see Building a workflow.

Whether a tool runs at all, asks, or is refused is a separate question — Autonomy and action policy. An approval answers a different one: not how much the agent may do, but who says yes when somebody has to.

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.