Recipe — multistep chain
Drive two tools from one chat message in Webbee — a multistep recipe where the classifier fills the second step's args from the first, in dependency order.
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, sdl
from pydantic import BaseModel, Field
class ListUnreadParams(BaseModel):
limit: int = Field(20, description="Max unread to return.")
# Read/list tools return SDL entities, not legacy dict wrappers.
# A single message is an sdl.Entity: core id/title/kind, plus the
# Correspondents facet (people.sender) and MessageState facet (comm.is_read).
class UnreadMessage(sdl.Entity, sdl.Correspondents, sdl.MessageState):
pass
# A list tool returns a concrete sdl.EntityList[T] subclass.
class UnreadList(sdl.EntityList[UnreadMessage]):
pass
# Declaring the return shape (data_model=) lets the platform validate
# downstream chain $REF paths against real field names at classify time.
# Read tools missing data_model trip validator V23 (WARN, promotable to ERROR).
@chat.function(
"list_unread",
description="Return the user's unread emails. Useful as a chain producer for 'summarize my unread' workflows.",
action_type="read",
chain_callable=True,
data_model=UnreadList,
)
async def list_unread(ctx, params: ListUnreadParams) -> ActionResult:
rows = await ctx.http.get(f"/mail/unread?limit={params.limit}")
msgs = [
UnreadMessage(id=m["id"], title=m["subject"], sender=m["sender"], is_read=False)
for m in rows
]
return ActionResult.success(
UnreadList(items=msgs, total=len(msgs), has_more=False),
summary=f"{len(msgs)} unread.",
)class CreateNoteParams(BaseModel):
title: str = Field(description="Short title.")
content_text: str = Field(description="The FULL note content — write the actual content, never placeholders.")
# The created note is an sdl.Entity — core id/title/kind plus the Bodied
# facet (content.body). id_projection declares the structural id field so
# the kernel can resolve this step's target id for any downstream step.
class Note(sdl.Entity, sdl.Bodied):
pass
@chat.function(
"create_note",
description="Create a note with the given title and content.",
action_type="write",
chain_callable=True,
data_model=Note,
id_projection="id",
)
async def create_note(ctx, params: CreateNoteParams) -> ActionResult:
note = await ctx.http.post("/notes", json=params.model_dump(exclude_none=True))
return ActionResult.success(
Note(id=note["id"], title=note["title"], body=params.content_text),
summary=f"Created note: {note['title']}",
)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
Step 1's UnreadList (its items — typed UnreadMessage entities with title/sender) is made available 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.
There is no "previous output" object on ctx. A downstream step receives the data it needs through its own typed params — the platform projects the producer's output into the consumer's params fields before the handler runs. So a step that needs the unread list as input simply declares it as a typed param:
class SummarizeParams(BaseModel):
messages: list[str] = Field(description="The unread message subjects to summarize, projected from the prior step.")
# The summary is an sdl.Entity — core id/title/kind plus the Excerptable
# facet (content.summary). data_model points at the entity class.
class SummaryRecord(sdl.Entity, sdl.Excerptable):
pass
@chat.function(
"summarize_unread",
description="Summarize a set of messages into one concise paragraph.",
action_type="read",
data_model=SummaryRecord,
)
async def summarize_unread(ctx, params: SummarizeParams) -> ActionResult:
joined = "\n".join(params.messages)
result = await ctx.ai.complete(
prompt=f"Summarize these emails concisely, plain text only:\n{joined}",
)
return ActionResult.success(
SummaryRecord(id="summary", title="Unread summary", summary=result.text),
summary=result.text,
)ctx.ai.complete(prompt=...) returns a result whose .text holds the model's reply. The platform handles model selection and provider routing for you.
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)When you accept, the platform guarantees it executes exactly what the confirmation card showed — the arguments that run are byte-identical to what you saw and approved, never silently changed in between.
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.ai, send the summary.
Where to next
Recipe — create a note
Create a note from chat in Webbee — a runnable extension recipe where the classifier generates the full note body itself and writes it into the tool args.
Recipe — extension that calls the LLM
Call an LLM from inside your Webbee extension with ctx.ai.complete — it routes through the user's BYOLLM provider automatically, or the platform LLM otherwise.