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:
mail.list_unread(read)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 responseYour 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:
- Runs read-only steps eagerly.
- Hits the destructive step → pauses, builds a consolidated confirmation card showing all destructive calls in the remaining chain.
- 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 rerunThis 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.