Imperal Docs
Recipes

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

mail/handlers_chat.py
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.",
    )
notes/handlers_chat.py
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):

  1. Webbee: "You have 12 unread, mostly about Q3 budget"
  2. 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

On this page