Chains
Do several things from one message. Webbee calls a tool, reads the real result, decides the next, and repeats — then renders one combined response.
When a user says "check my unread mail and create a follow-up note for the most important one", Webbee handles it in one message — but not by planning two steps up front. Its brain runs an iterative tool-use loop, like Claude Code turned inward, bounded to a handful of steps:
- It calls
mail.list_unreadand gets the real messages back. - It reads those actual messages in its own context and picks the important one.
- It calls
notes.create_notewith a title and body it authors from what it just saw. - One combined response comes back to the user.
There's no fixed script decided in advance. Each step's real result is folded into the brain's context, and it reads that result before choosing what to do next.
Why this matters
Without it, the user asks twice:
"check my unread mail" → list comes back "now make a note about the most important one" → note created
With one loop, the user asks once and Webbee does both — less friction, closer to how people actually talk.
How the loop moves forward
There is no special "previous step" object and nothing auto-copies one tool's output into another's params. Instead, each result is real data the brain reads, and it writes the next call's arguments itself:
"check my unread mail and note the most important one"
↓
brain → mail.list_unread → real result: 12 messages, folded into context
↓ (brain reads it, picks the important thread)
brain → notes.create_note(...) → args authored from what it just read
↓
one combined response rendered back to the userYour tool taking part
A tool is a good loop participant when its output is typed and readable and its inputs are clearly named. Two roles show up:
A read tool declares a typed return contract with data_model= (validator V23, required on read tools; it feeds the tool catalog the brain sees). A single record is an sdl.Entity subclass; a list result is a real sdl.EntityList[T]:
from imperal_sdk import ActionResult, sdl
from pydantic import BaseModel, Field
class ListUnreadParams(BaseModel):
limit: int = Field(20, description="Max unread to return.")
# Single-record SDL entity. sdl.MessageState adds is_read/sent_at/etc.
class Email(sdl.Entity, sdl.MessageState):
pass
# List result is a real sdl.EntityList[T] — never a {"messages": [...]} wrapper.
class EmailList(sdl.EntityList[Email]):
pass
@chat.function(
"list_unread",
description="Return the user's unread emails. Handy as an early step for 'summarize my unread'.",
action_type="read",
data_model=EmailList,
)
async def list_unread(ctx, params: ListUnreadParams) -> ActionResult:
rows = await ctx.http.get(f"/mail/unread?limit={params.limit}")
emails = [Email(id=m["id"], title=m["subject"], is_read=False) for m in rows]
return ActionResult.success(
EmailList(items=emails, total=len(emails)),
summary=f"{len(emails)} unread.",
)Typed entities — their core id/title and facet roles — are exactly the shape the brain can read cleanly and reason over before its next move.
A consumer is just a later tool the brain calls with arguments it derived from the producer's real result. You declare the params you need; the brain fills them from what it saw:
from imperal_sdk import ActionResult
from pydantic import BaseModel, Field
class CreateNoteFromMailParams(BaseModel):
thread_id: str = Field(description="The mail thread to base the note on.")
title: str = Field(description="Note title.")
@chat.function(
"create_note_from_mail",
description="Create a note from a mail thread.",
action_type="write",
)
async def create_note_from_mail(ctx, params: CreateNoteFromMailParams) -> ActionResult:
thread = await ctx.http.get(f"/mail/threads/{params.thread_id}")
note = await ctx.http.post("/notes", json={
"title": params.title,
"content": format_thread_for_note(thread),
})
return ActionResult.success(
{"note_id": note["id"]},
summary=f"Note created: {note['title']}",
)Write your params model normally. The brain reads the earlier result and passes a real thread_id — clear names and descriptions make that easy and reliable.
Confirmation mid-loop
When the brain reaches a destructive step, the platform pauses before running it and shows a single consolidated confirmation card. On accept, it runs exactly what was shown — byte-identical arguments, no re-interpretation.
User: "summarize my unread and delete the spam"
↓
brain → mail.list_unread (read) → 12 unread, 4 are spam
↓
brain → mail.delete_spam (destructive) → platform pauses
↓
Card: "Delete 4 spam threads? [Yes] [Cancel]"
↓
On Yes: the exact action runs — same arguments, no re-interpretationWhat the user confirms is exactly what runs.
Anti-patterns
Guarantees
Prior results are read-only
The brain reads earlier results from its context; it can't reach back and mutate a step that already ran.
Single-user scope
The whole loop runs inside one user's scope — one step's data never crosses to another user.
Validated outputs
A read tool's data_model return is validated against its typed contract before the brain ever reads it.
What's next
Confirmations
Confirmations are the chat flow that gates every write and destructive action: the user sees exactly what runs and the held call fires byte-identical on yes.
Audit & security
Audit and security in Imperal Cloud: the action ledger, retention classes, tenant isolation, and exactly what every Webbee extension action records for free.