How Webbee runs multi-step actions
How one natural-language message becomes a multi-step turn — Webbee calls a tool, reads the real result, decides the next call, and repeats until it can answer.
Webbee's brain runs an iterative tool-use loop — think of it as Claude Code turned inward, onto your own data and extensions. It does not plan every step up front. It calls a tool, folds the real result back into its own context, reads it, decides what to do next, and repeats — until it has enough to answer you. Then it replies.
That is why one message like "query the database, save the results as a note, and email it to the team" simply works. Webbee doesn't fire three tools blindly and hope the outputs line up. It runs the query, sees the actual rows, then decides to call the notes tool, sees that result, then calls mail — each step informed by what really happened in the step before it.
The loop is a single brain reasoning over your typed data, bounded to a handful of steps per turn so a turn always terminates.
Older material called this "chain dispatch." It's the same idea, described accurately: a loop, not a pre-computed plan.
Single-step vs multi-step
Most messages are single-step: one message, one tool call, one result shown to you. That's the common case.
A turn becomes multi-step when answering you takes more than one action — often across more than one extension. There is no separate "plan" object and no dependency graph worked out ahead of time. Webbee just keeps going around the loop: each result it reads may prompt the next call.
| Single-step | Multi-step | |
|---|---|---|
| Tool calls | One | Several, one after another |
| Extensions | Usually one | Often more than one |
| How the next step is chosen | — | Webbee reads the real result of the last call, then decides |
| Confirmation | Per destructive action | Each destructive action pauses for its own card |
Multi-step is not a macro or a pipeline you define. It emerges from the loop reading real results and deciding.
How a multi-step turn flows
Take the database → note → email request as an example. It runs as an iteration, not a plan:
You: "Query orders, write a note with the count, and email me the note."
- Webbee calls the database read tool. The real result comes back — say
{"row_count": 248}— and is folded into its context. - It reads that, then calls the notes write tool with the actual count. The created note (its id, its title) folds back in.
- It reads that, then calls the mail tool with the note's content. Mail returns; that folds in too.
- Webbee now has everything it needs, and writes you a reply.
If you had typed the same three things in a different order, the flow is the same. Webbee acts on what each step actually returns, so the order it runs things follows the data — not the order you happened to mention them.
If a step returns an error, that error folds back into context as well. Webbee reads it and can adjust or stop and tell you, instead of barreling ahead on a value it never received.
Typed returns feed the loop
For the loop to hand exact values to the next call — rather than re-guessing them from a prose summary — read tools declare a typed return contract with data_model=. It's a Pydantic model describing the shape of ActionResult.data:
from imperal_sdk import Extension, ChatExtension, ActionResult
from pydantic import BaseModel
ext = Extension(
"orders",
display_name="Orders",
description="Orders extension — look up and count your orders.",
icon="icon.svg",
actions_explicit=True,
)
chat = ChatExtension(ext, "orders", "Look up orders.")
class CountOrdersParams(BaseModel):
status: str = "open"
class OrderCount(BaseModel):
row_count: int
status: str
@chat.function(
"count_orders",
description="Count the user's orders, optionally filtered by status.",
action_type="read",
data_model=OrderCount,
)
async def count_orders(ctx, params: CountOrdersParams) -> ActionResult:
rows = await ctx.store.query("orders", where={"status": params.status})
return ActionResult.success(
data={"row_count": len(rows.data), "status": params.status},
summary=f"Found {len(rows.data)} {params.status} orders.",
)data_model= is required on read tools (validator V23). It's emitted into your manifest and ingested into the platform catalog, so Webbee knows a read tool's exact return fields and can pass precise values into the next call. Use the same field names as your input params for round-trip symmetry — that avoids the drift where an input field is content_text but the output is content. It's recommended on write and destructive tools too (V24, warn-only).
Confirmation before anything destructive
Reading is safe to iterate. Anything destructive is not. When the loop reaches a destructive action, Webbee pauses and shows you a confirmation card — a Pre-Authorized Action Execution gate — that consolidates exactly what it is about to do, and to what. Nothing runs until you accept, and on accept it runs exactly what the card showed — no more, no less. There is no bypass flag.
If a single turn reaches more than one destructive action, you see one card for each, in turn, as the loop gets to it.
Single-user scope
Every step of every turn runs as the one signed-in user. A turn can read your data and write your data; it can never reach another user's. This is not a convention Webbee tries to honour — it's a federal invariant enforced underneath the loop, so a multi-step turn has exactly the same reach as a single call.
Not the loop: two things that look similar
@ext.schedule (scheduled jobs)
A scheduled task runs on a timer, with no chat and no brain in the loop. It isn't a turn — there's no message, and no reading-a-result-then-deciding. A scheduled job that touches three stores does so as one fixed unit of code you wrote, not as three tool calls Webbee chose.
Multi-turn conversation
Asking a follow-up in your next message that builds on the last answer is a multi-turn conversation, carried by history. The multi-step loop is the opposite — it all happens inside the handling of a single message. Across messages is conversation; within one message is the loop.
Validators
| Rule | Level | What it checks |
|---|---|---|
V23 | ERROR | Read tool missing its data_model= typed return contract |
V24 | WARN | Write/destructive tool missing data_model= |
V4 | ERROR | action_type not one of read / write / destructive |
What's next
Action types
read / write / destructive — what each one gates and audits.
@chat.function reference
Every decorator kwarg, including data_model, in full.
Skeletons
The ambient context the brain already has before you type.
Web-kernel context
The full ctx surface available in every handler.
Federal invariants
Single-user scope and the confirmation guarantee.
Federal invariants
Federal invariants — the runtime contracts Imperal Cloud enforces at the boundary so every extension stays safe, audited, and impossible to bypass at runtime.
Action types
read / write / destructive action types — how Imperal Cloud gates side-effects, audit, and confirmations for every chat function declared in your extension.