Imperal Docs
Core Concepts

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-stepMulti-step
Tool callsOneSeveral, one after another
ExtensionsUsually oneOften more than one
How the next step is chosenWebbee reads the real result of the last call, then decides
ConfirmationPer destructive actionEach 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."

  1. Webbee calls the database read tool. The real result comes back — say {"row_count": 248} — and is folded into its context.
  2. It reads that, then calls the notes write tool with the actual count. The created note (its id, its title) folds back in.
  3. It reads that, then calls the mail tool with the note's content. Mail returns; that folds in too.
  4. 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

RuleLevelWhat it checks
V23ERRORRead tool missing its data_model= typed return contract
V24WARNWrite/destructive tool missing data_model=
V4ERRORaction_type not one of read / write / destructive

What's next

On this page