Imperal Docs
Recipes

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

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

@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):

  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.llm, send the summary.

Where to next

On this page