Recipe — multistep from one message
Drive two tools from one chat message in Webbee. Its brain runs an iterative tool-use loop — call a tool, read the real result, decide the next step.
A common ask: "check my unread mail and make a note about the most important one". That's two tools — mail.list_unread (read) then notes.create_note (write) — driven from one message. Webbee handles it the way Claude Code works, turned inward: an iterative loop, not a canned plan.
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
# data_model= is this read tool's typed return contract: the platform
# validates the returned shape against it and ingests it into the tool
# catalog, so the brain knows what this tool hands back. Required on read
# tools — a read tool missing it trips validator V23.
@chat.function(
"list_unread",
description="Return the user's unread emails.",
action_type="read",
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).
class Note(sdl.Entity, sdl.Bodied):
pass
@chat.function(
"create_note",
description="Create a note with the given title and content.",
action_type="write",
data_model=Note,
)
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']}",
)What Webbee's brain does
Webbee's brain runs an iterative tool-use loop, bounded to a handful of steps. On each step it calls one tool; the real tool result is folded back into its context; it reads that actual result and decides the next tool — repeating until it can answer. It never commits to a fixed sequence up front.
So the two-tool ask is real, done by iteration:
- The brain calls
mail.list_unreadand sees the messages that actually came back — real subjects, real senders — in its context. - Reading them, it picks the most important one and calls
notes.create_note, writing atitleandcontent_textfrom what it just saw.
There's no "previous output" object to wire up and no dependency graph to declare. The brain reads the prior result from its own context and authors the next call's arguments itself — the same way you would if you were doing it by hand.
That extends naturally to longer runs. Ask it to "pull this week's unread and summarize them" and it can list, then feed the subjects it saw into a ctx.ai.complete(prompt=...) call to write the summary — each step chosen from the real result of the last.
When a step is destructive
If a step would change or delete something, the platform pauses before running it, shows a consolidated confirmation card, and waits for you:
User: "delete my spam and archive the rest"
↓
Webbee reads the unread list, sorts spam from the rest
↓
Before deleting, it pauses:
Confirmation card: "Delete 4 spam threads and archive 12 others?"
↓
You click Yes → it runsWhen you accept, the platform runs exactly what the card showed — the arguments that execute are byte-identical to what you saw and approved, never re-interpreted 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
List → read → act
Webbee lists, reads the real rows, then acts on the one it chose — each step decided from the last result.
Search → disambiguate → update
Find candidates, ask you to pick (in the panel), then act on the chosen one.
Aggregate → summarize → email
Pull data, summarize it via ctx.ai, then 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.