This article is published in English.
From "Go Ahead" to Done: State, Approval and Idempotency for Acting Agents
Learn how explicit proposals, bound approvals, revalidation, idempotency keys and outcome verification turn a multi-agent refund workflow into one you can trust.
A multi-agent demo usually ends with a convincing answer. A production system starts its hardest work right after that, when the user replies "Great, go ahead" and the software has to turn a conversational suggestion into a real change in another system. This article follows a single refund request through a coordinator, a specialist agent, stored proposals, human approval and execution, and shows which responsibilities belong to the model and which must stay in application code. By the end you will have a concrete checklist for deciding whether an agentic workflow is ready to act on the world.
What changes when an assistant has to act
Up to the moment of approval, the assistant only had to interpret a request and produce a helpful reply. Once the user says yes, a different set of obligations appears. The system must recall precisely what it offered, locate the correct record, pick the component responsible for the task, confirm that the action still makes sense, check that this user may authorize it, call an external service, cope with failures, and finally determine something that sounds simple but rarely is: whether the action really took place.
The familiar demo architecture covers only the first part. An orchestrator receives the request, delegates to specialists, the specialists call tools, and an answer comes back. That structure is valuable, yet for real-world actions it is merely the entry point. Acting safely requires explicit state, authorization, workflows that survive waiting, revalidation, idempotency, recovery from ambiguous failures and evidence that the job is finished. That is the point at which agent design becomes an architecture problem rather than a prompting problem.
Tracing a single refund request
Take a request a customer might send a support assistant: look at order #4821, decide if it is eligible for a refund, and if so draft one for approval. A human agent would break that into roughly ten steps:
- Work out what the customer is asking for.
- Fetch the order.
- Find the refund policy that applies.
- Compare the order against the policy.
- Resolve anything still unknown.
- Compute the refund amount.
- Draft a proposal.
- Request approval.
- Carry out the refund.
- Confirm that it succeeded.
In software, each of those transitions needs an owner and a place to live. A workable sequence carries the request from the user to an orchestrator, then to a specialist that collects evidence and drafts a proposal, which is approved, executed and finally verified.
- Routing the request is the orchestrator's job.
- The specialist performs the domain investigation.
- Tools connect the system to documentation and live records.
- Explicit state captures the proposed action.
- The user approves one specific proposal.
- Application code performs the operation.
- Verification establishes whether the intended outcome occurred.
A single principle ties these together:
The model may decide what ought to happen; the application decides what is permitted to happen.
The snippets that follow are architectural sketches in Python using LlamaIndex. Model configuration, storage and service integrations are left out, and names such as llm and policy_retriever stand in for components your application would provide. Some snippets also have line breaks collapsed (for example, statements joined on one line), so read them as outlines rather than copy-paste code.
Separate routing from specialist work
The quickest way to build an agent is to hand it every tool at once: documentation search, order lookup, payment checks, refunds, email, reporting. For a small application that is perfectly reasonable. As responsibilities accumulate, though, the agent's job becomes hard to describe in a sentence, and a delivery-time question ends up sharing a crowded toolbox with an operation that moves money.
A cleaner boundary splits two concerns: deciding where a request belongs, and doing specialized work on it. For the support assistant, that means a coordinator that fields general questions and hands refund-related requests to a dedicated specialist. The specialist is built from LlamaIndex's FunctionAgent:
from llama_index.core.agent.workflow import FunctionAgent
Its definition gives it a narrow mandate, three tools and permission to hand control back to the coordinator. Notice that the system prompt asks it to explain its evidence before proposing anything:
refund_specialist = FunctionAgent(
name="refund_specialist",
description="Checks refund eligibility and prepares proposals.",
system_prompt=(
"Check the order and the applicable policy. "
"Explain your evidence before proposing a refund. "
"Hand back unrelated requests to the coordinator."
),
tools=[get_order, search_policy, prepare_refund],
llm=llm,
can_handoff_to=["coordinator"],
)
The description field is not decoration; it is part of the architecture. A label like "helpful assistant" communicates nothing. "Checks refund eligibility and prepares proposals" defines a responsibility that can be tested and audited. In LlamaIndex, the FunctionAgent and AgentWorkflow classes are designed for exactly this kind of explicit handoff. If you are still choosing a framework, the comparison of LangChain and LlamaIndex covers the broader trade-offs.
Adding agents is not automatically an improvement. Each specialist introduces coordination overhead, so every one should justify its existence. Here, a pair of agents, a coordinator plus a refund specialist, draws a meaningful boundary without extra overhead.
Wiring the coordinator to the specialist
The coordinator and the workflow that connects both agents come from the same module:
from llama_index.core.agent.workflow import AgentWorkflow
The coordinator has access to policy search for general questions and may hand off to the refund specialist. The AgentWorkflow registers both agents and makes the coordinator the root that receives every request. In the snippet, the closing parenthesis of the coordinator and the workflow assignment ended up on one line; they are two separate statements:
coordinator = FunctionAgent(
name="coordinator",
description="Answers general questions and routes refund requests.",
system_prompt=(
"Use documentation for general questions. "
"Hand refund requests to the refund specialist."
),
tools=[search_policy],
llm=llm,
can_handoff_to=["refund_specialist"],
)workflow = AgentWorkflow(
agents=[coordinator, refund_specialist],
root_agent="coordinator",
)
With this in place the request has a path. The coordinator recognizes a refund question and hands it over; the specialist fetches the order, searches the policy, notes what is still missing and, once the evidence is sufficient, drafts a proposal.
The route through the conversation will vary. One customer supplies the delivery date up front; another asks about refund rules first and only mentions the order three messages later. The agents can absorb that variation, while the application continues to define which capabilities exist and the limits on using them.
Conversations can stay flexible while consequential operations stay controlled.
Evidence before eligibility
Sending the request to the right agent does not make that agent's conclusion right. The specialist still has to gather evidence.
Suppose the policy allows unopened products to come back inside a 30-day window, while order records show that order #4821 arrived 12 days ago. These facts come from two different systems and both are necessary: the policy states the rule, the order describes the situation, and eligibility emerges only when they are combined. Retrieval therefore becomes a step in the workflow. The specialist searches policy documents while separately loading the current order record.
A minimal policy-search tool starts with an async signature and a docstring that tells the agent what the tool is for:
async def search_policy(question: str) -> list[dict]:
"""Find policy passages relevant to a customer request."""
The body retrieves matching passages and returns each one together with its source title and section. As with the previous snippet, the aretrieve call and the return statement belong on separate lines:
matches = await policy_retriever.aretrieve(question) return [
{
"text": match.node.get_content(),
"source": match.node.metadata.get("title"),
"section": match.node.metadata.get("section"),
}
for match in matches
]
What matters is what travels with the text: its provenance. If the assistant later claims the order falls inside the refund window, the explanation should be able to cite the policy passage that defines that window. Provenance has limits, though:
A citation cannot prove a reading is right; what it does is let someone check it.
The unopened condition exposes a gap: nothing in the order system records whether the package was opened. The correct move is not to guess but to ask the customer. A robust design represents missing information explicitly instead of letting uncertainty dissolve into a confident recommendation.
Conversation history is not application state
Assume the investigation is complete. The specialist reports that order #4821 qualifies for a €79 refund and asks whether to proceed. The user answers yes.
Routing worked, the investigation worked and the tools returned their evidence, yet a gap remains. The system cannot say precisely what was approved: the order, the sum and currency, and the version of the offer are all implied rather than recorded. If the conversation touched on two orders, or the amount was recalculated after the offer, the word yes becomes ambiguous.
This is why consequential actions must not live only in the chat transcript. The application needs an explicit record of the pending action, such as this one:
pending_action = {
"proposal_id": "refund-proposal-17",
"order_id": "4821",
"amount_minor": 7900,
"currency": "EUR",
"status": "awaiting_approval",
"evidence": [
"returns-policy:section-3",
"order:4821",
],
}
The status field is useful, but proposal_id is the one that carries the weight. Approval should point at the exact proposal the user saw. If the amount changes, it is a new proposal. If the order changes, it is a new proposal. If fresh evidence alters the recommendation, it is a new proposal. Money is stored as amount_minor in integer cents, which avoids floating-point rounding surprises, and the evidence list preserves which policy section and order record justified the offer.
The transcript exists for understanding; structured state exists for acting.
The conversation helps the model interpret what "go ahead" refers to. The stored proposal gives the application something unambiguous to execute.
Approval as data rather than words
The naive way to record consent is a single fact:
user said yes
A safer model binds identity, proposal, action and context together:
user X approved proposal Y,
containing action Z,
under the current conditions.
That distinction becomes decisive the moment an agent can affect external systems. Consent should tie a particular person to a particular proposed action, never to something the model reconstructs afterwards. The proposal therefore needs to carry every operational detail that matters:
- the target record
- the amount
- the currency
- the supporting evidence
- the current status
- a unique proposal identifier
Natural language explains the action to the user; the structured proposal defines it for the system.
Waiting is part of the workflow
Human approval also introduces time. The user may respond immediately, after lunch or the next day after closing the browser. When a workflow depends on a person, pausing is not an edge case but a normal phase, so pending work must be persisted and resumed later.
LlamaIndex provides workflow context objects and ways to pause for human input, which give this interaction some structure. Durable storage, authorization, expiry rules and the surrounding application lifecycle, however, remain your responsibility.
For something as consequential as a refund, a sound division of labour is to let the agent prepare the proposal and let ordinary application code execute the approved operation. The approval handler begins by loading the stored proposal:
async def approve_refund(proposal_id, user):
proposal = await proposals.load(proposal_id)
It then checks permissions, confirms the proposal is still pending, revalidates it, calls the payment service with the proposal ID as the idempotency key, and records the receipt. In the snippet these awaited calls run together on shared lines; each await is its own statement:
await permissions.require(
user,
"approve_refund",
proposal,
) await proposals.require_pending(proposal) await refunds.revalidate(proposal) receipt = await payments.refund(
order_id=proposal.order_id,
amount_minor=proposal.amount_minor,
idempotency_key=proposal.id,
) await proposals.mark_completed(
proposal.id,
receipt,
) return receipt
Several boundaries are hidden in that short function:
- The proposal comes from stored state, not from the conversation.
- Authorization is enforced outside the model.
- The code confirms the proposal is still awaiting approval, which guards against a second execution through this path (under concurrency, this check should be atomic with the status update).
- Eligibility is revalidated just before acting.
- The payment call carries a stable idempotency key.
- The outcome is persisted.
The sequence runs from a persisted proposal through an authorized approval, a fresh validation and the execution itself to a stored outcome. That sequence matters far more than whether the agent phrased its message perfectly.
Revalidate immediately before acting
Why check again after the user has already said yes? Because the world keeps moving while the system waits. The order might have been refunded through another support channel, the payment status might have changed, someone might have edited the order, or a parallel workflow might already have performed the operation.
A proposal is a snapshot of what looked correct at the moment it was drafted. Execution comes afterwards, sometimes much later. Before a consequential operation, the application should confirm that the assumptions behind the proposal still hold.
A yes grants permission to act; it does not stop the world from changing.
The longer a proposal can sit pending, the more important this becomes. Pairing revalidation with an expiry time on proposals keeps the window of stale assumptions bounded.
Retries must not repeat the action
Consider another failure. The user approves, the application sends the refund, the payment provider processes it, and the network drops before the response arrives. The client retries. Should the customer receive a second €79? Clearly not.
Idempotency prevents that. A stable operation identifier lets any external service that supports idempotency recognize two requests as one logical operation. Instead of reading the retry as "issue another refund", the service treats it as a request for the status or outcome of the operation already tied to proposal #17. Using the proposal ID as the key works because each approved proposal should produce at most one refund. The mechanics on the server side are covered in more depth in understanding idempotency keys in Node.js POST endpoints.
This rarely features in polished multi-agent demos. But as soon as a system can move money, send messages, change records, open tickets or trigger any other real-world effect, retries are part of the architecture.
"Unknown" is a legitimate outcome
Now the most uncomfortable case: the payment call hits a timeout. Perhaps nothing happened, or perhaps the money moved a moment before the connection died. For the customer, the difference is €79.
Telling the user that the refund failed and is being retried would be wrong, because nothing supports that claim. All the system has is an absence of confirmation, which is a different fact. A dependable workflow keeps that uncertainty visible: rather than repeating the operation, it queries the status of the existing transaction, and in the meantime tells the user something accurate, such as that completion could not be confirmed and the status is being checked.
This discipline holds at every stage, not just at payment time:
- When an order lookup errors out, the order's existence is unknown, not disproven.
- When a policy search errors out, the policy question remains open rather than answered with none.
- A timeout does not necessarily mean the operation failed; completion is unconfirmed.
Reliable systems model these as distinct states instead of collapsing them into "failed" or "not found".
A returned tool call does not mean done
Here the architecture moves beyond orchestration. A tool call coming back is not the same as the work being complete. An error response means it is not. A timeout means it is not. If the application cannot tell whether the refund happened, it is certainly not finished.
A stronger definition of completion is that the system can show evidence that the intended result really happened. That shifts the contract. The goal is not this:
call refund()
The goal is this:
establish that refund proposal #17
for order #4821
was successfully processed exactly once
Those are very different guarantees, and that difference is why orchestrators and specialists are only the start of the design.
Nine questions before a multi-agent system may act
Before connecting an AI workflow to operations with real consequences, work through these questions:
- Does every agent have a bounded responsibility you can state in one sentence?
- Can you trace an important decision back to the documents and records behind it?
- Can missing information stay explicitly unknown, or does uncertainty quietly turn into a confident recommendation?
- Is the proposed action stored as structured state rather than existing only in the conversation?
- Does the user approve one precise proposal, so that "yes" authorizes something specific?
- Are permissions enforced in application code, with the model limited to recommending?
- Is the proposal revalidated right before execution, since conditions may have changed?
- Can execution survive retries safely, with idempotency treated as part of correctness?
- Is the outcome confirmed before anything is reported as complete, rather than treating a tool call as proof?
If some of these have no clear answer, you may still have an impressive demo, but probably not yet a system that should be trusted to act.
Wrapping up
Revisit the original request: check order #4821 and prepare a refund for approval. Every part of it now has a home. The orchestrator routes, the specialist gathers evidence, tools reach documentation and live records, explicit state holds the proposal, a person signs off on one precise action, application code checks permissions and revalidates, the payment system executes, and the outcome is stored and confirmed. At that point the assistant can give a pleasantly boring reply: the €79 refund for order #4821 has been processed, with a confirmation attached. That sentence is trustworthy because of everything behind it.
As a system grows, the same questions keep paying off. Is each capability owned by someone? Can the evidence behind a decision be inspected? Does the workflow preserve uncertainty and recover when a dependency fails? Can a person see exactly what they are approving, and can the system prove the intended outcome occurred? The answers tell you where another specialist agent adds value, where a plain function suffices, and where a firmer boundary is required. The best agentic experience may end with a single ordinary word, "Done", and the architecture is what makes it believable.