Recipe — multistep chain
When one user message triggers two or three tools in sequence
A common pattern: "check my unread mail and create a note about the most important one". That's two tools — mail.list_unread (read) followed by notes.create_note (write). This recipe shows the federal-clean way.
The two tools
from imperal_sdk import ChatExtension, ActionResult
from pydantic import BaseModel, Field
class ListUnreadParams(BaseModel):
limit: int = Field(20, description="Max unread to return.")
@chat.function(
description="Return the user's unread emails. Useful as a chain producer for 'summarize my unread' workflows.",
action_type="read",
chain_callable=True,
)
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,
"count": len(msgs),
}class CreateNoteParams(BaseModel):
title: str = Field(description="Short title.")
content_text: str = Field(description="The FULL note content — write the actual content, never placeholders.")
@chat.function(
description="Create a note with the given title and content.",
action_type="write",
chain_callable=True,
)
async def create_note(ctx, params: CreateNoteParams):
note = await ctx.http.post("/notes", json=params.model_dump(exclude_none=True))
return {"text": f"Created note: {note['title']}", "note_id": note["id"]}Both chain_callable=True (the default) — they can participate in chains.
What the web-kernel does
Given the user message "check my unread and create a note about the most important one", the classifier emits:
{
"intent": "chain",
"action_plans": [
{
"tool": "mail.list_unread",
"args": {"limit": 20}
},
{
"tool": "notes.create_note",
"args": {
"title": "<filled by classifier from prior step>",
"content_text": "<filled by classifier from prior step>"
}
}
]
}the chain executor runs step 1, makes its output available to the classifier for step 2 args, then runs step 2.
How step 2 sees step 1's output
In the web-kernel's chain orchestrator, step 1's output (messages, count) is exposed to step 2's classifier round. The LLM picks the most important message and writes a summary into content_text — typically a 50-100 word paragraph.
For tools that need typed access in code, use ctx.prior:
@chat.function(description="...")
async def summarize_unread(ctx, params):
msgs = ctx.prior.list_unread.messages # ← typed access from chain producer
summary = await ctx.llm.create_message(
system="Summarize concisely. Plain text only.",
messages=[{"role": "user", "content": f"Summarize these emails:\n{msgs}"}],
)
return {"text": summary.text}Mixed read + destructive chain
When a chain has destructive steps, the web-kernel pauses for confirmation:
User: "delete my spam and archive the rest"
↓
Step 1: mail.classify (read) — partitions unread into spam vs not
↓
Step 2: mail.delete (destructive) — web-kernel pauses
↓
Confirmation card: "Delete 4 spam threads and archive 12 others?"
↓
User clicks Yes → typed-iterate dispatch
↓
Step 2 runs (deletes), step 3 runs (archives)Federal I-CONFIRMATION-EXECUTES-WHAT-USER-SAW ensures the args on accept are byte-identical to what was on the card.
Try it in chat
"check my unread and make a note about the most important one"
You'll see (in chat):
- Webbee: "You have 12 unread, mostly about Q3 budget"
- Webbee: "Created note: 'Q3 budget — Sarah's reply'"
Two messages, one user turn.
Patterns
Read → Read → Write
Common: list, classify, then act. Each step refines the choice.
Search → Disambiguate → Update
Find candidates, ask user to pick (panel), then act on the chosen one.
Aggregate → Summarize → Email
Pull data, summarize via ctx.llm, send the summary.