Imperal Docs
Guides

Chains

Multi-step actions — when one user message triggers several tools in sequence

When a user says "check my unread mail and create a follow-up note for the most important one", that's two tools in sequence:

  1. mail.list_unread (read)
  2. notes.create_note (write)

The web-kernel's chain orchestrator runs them in order, threads outputs forward, and renders one combined response. This page covers chains end to end.

Why chains exist

Without chains, the user would have to ask twice:

"check my unread mail" → list comes back "now make a note about the most important one" → note created

With chains, the user asks once and Webbee does both. Saves friction; matches how humans actually talk.

What the web-kernel does

User message

Intent classifier emits a 2+ step plan:
{
  "intent": "chain",
  "action_plans": [
    {"tool": "mail.list_unread", "args": {"limit": 20}},
    {"tool": "notes.create_note", "args": {"title": "<from step 1>", ...}}
  ]
}

the chain executor runs steps sequentially:
  step 1 → output stored in ctx.prior.list_unread
  step 2 → params model can read ctx.prior.list_unread.messages

chain_renderer combines outputs into one chat response

Your tool participating in chains

By default, every @chat.function is chain_callable=True — it can be a step in a chain. Three patterns:

A read-only tool that produces structured output for downstream:

class ListUnreadParams(BaseModel):
    limit: int = Field(20, description="Max unread to return.")

@chat.function(
    description="Return the user's unread emails. Useful as the first step in chains like 'summarize my unread'.",
    action_type="read",
)
async def list_unread(ctx, params: ListUnreadParams):
    msgs = await ctx.http.get(f"/mail/unread?limit={params.limit}")
    return {
        "text": f"{len(msgs)} unread.",
        "messages": msgs,           # ← key for chain consumers
        "count": len(msgs),
    }

The messages and count fields become available to the next step via ctx.prior.list_unread.*.

A tool that takes a previous step's output:

class CreateNoteFromMailParams(BaseModel):
    thread_id: str = Field(description="The thread UUID to base the note on.")
    title: str = Field(description="Note title.")

@chat.function(
    description="Create a note from a mail thread.",
    action_type="write",
)
async def create_note_from_mail(ctx, params):
    thread = await ctx.http.get(f"/mail/threads/{params.thread_id}")
    note = await ctx.http.post("/notes", json={
        "title": params.title,
        "content": format_thread_for_note(thread),
    })
    return {"text": f"Note created: {note['title']}", "note_id": note["id"]}

The classifier would emit thread_id from the previous step's output. You write your params model normally — the orchestrator does the projection.

@chat.function(description="Archive old threads. Returns archived ids.", action_type="destructive")
async def archive_old_threads(ctx, params):
    archived = await ctx.http.post("/mail/archive-old", json={"days": params.days})
    return {
        "text": f"Archived {len(archived)} thread(s).",
        "archived_ids": archived,    # available downstream
    }

A destructive tool can also produce output. The web-kernel pauses for confirmation BEFORE running it, then executes if accepted, then makes its output available to next chain steps.

Confirmation in chains

When any chain step is destructive, the web-kernel:

  1. Runs read-only steps eagerly.
  2. Hits the destructive step → pauses, builds a consolidated confirmation card showing all destructive calls in the remaining chain.
  3. On accept, runs the destructive steps typed-iterate with byte-identical args.
User: "summarize my unread and delete the spam"

Step 1: mail.list_unread (read) → 12 unread, 4 are spam

Step 2: mail.delete_spam (destructive) → web-kernel pauses

Card: "Delete 4 spam threads? [Yes] [Cancel]"

On Yes: typed-iterate dispatch — same args, no LLM rerun

This is federal I-CONFIRMATION-EXECUTES-WHAT-USER-SAW in chain form.

The typed-pipe (ctx.prior)

Since v4.1, chain step outputs are available to downstream steps as typed namespaces:

# In step 2 of a chain that started with list_unread:
@chat.function(...)
async def summarize_unread(ctx, params):
    msgs = ctx.prior.list_unread.messages   # typed access — IDE auto-completes
    summary = await ctx.llm.create_message(...)
    return {"text": summary}

The web-kernel infers the prior step's output schema from your tool's manifest. Your IDE understands the shape because the SDK generates type stubs.

Anti-patterns in chains

Federal invariants in chain land

🔗

I-CHAIN-TYPED-PIPE

ctx.prior is typed — schemas inferred from the producer's manifest.

📋

I-CHAIN-PRIOR-IMMUTABLE

Downstream steps can read ctx.prior, never mutate it.

🔐

I-CHAIN-PRIOR-TENANT-SCOPED

Each chain runs in a single user/tenant scope. ctx.prior never leaks across users.

📐

I-CHAIN-OUTPUT-SCHEMA-VALIDATION

Producer outputs are validated against declared schemas (when present) before being made available downstream.

What's next

On this page