Chains
Chains sequence multiple tools from one user message, dependency-ordered, threading each step's output into the next and rendering one combined Webbee response.
When a user says "check my unread mail and create a follow-up note for the most important one", that's two tools in sequence:
mail.list_unread(read)notes.create_note(write)
The platform runs them in order, threads each step's output forward into the next, and renders one combined response. This page covers chains end to end.
Why chains exist
Without chains, the user would have to ask twice:
"check my unread mail" → list comes back "now make a note about the most important one" → note created
With chains, the user asks once and Webbee does both — less friction, closer to how people actually talk.
How data flows between steps
You never read a special "previous step" object. Instead, a downstream step receives the prior step's output through its own typed params — the platform maps the producer's output fields onto the consumer's parameters when it builds the chain. You just declare the params your tool needs; the platform fills them.
"check my unread mail and note the most important one"
↓
Step 1 mail.list_unread → returns { messages: [...], count: 12 }
↓ (the platform carries the output forward)
Step 2 notes.create_note(...) → its params are filled from step 1's output
↓
one combined response is rendered back to the userYour tool participating in chains
By default every @chat.function is chain_callable=True — it can be a step in a chain. Three patterns:
A read-only tool that produces typed output for downstream steps. A read tool declares a data_model (V23) — 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. Useful as the first step in chains like '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.",
)The typed entities — their core id/title and facet roles — are what the platform can feed into a later step's params.
A tool that uses a previous step's output — it just declares the params it needs:
from imperal_sdk import ActionResult
from pydantic import BaseModel, Field
class CreateNoteFromMailParams(BaseModel):
thread_id: str = Field(description="The 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']}",
)You write your params model normally — the platform projects the previous step's output into thread_id for you. There's no "previous output" object to read.
from imperal_sdk import ActionResult
from pydantic import BaseModel, Field
class ArchiveOldThreadsParams(BaseModel):
days: int = Field(30, description="Archive threads older than this many days.")
@chat.function(
"archive_old_threads",
description="Archive old threads. Returns archived ids.",
action_type="destructive",
chain_callable=True,
effects=["archive:email"],
)
async def archive_old_threads(ctx, params: ArchiveOldThreadsParams) -> ActionResult:
archived = await ctx.http.post("/mail/archive-old", json={"days": params.days})
return ActionResult.success(
{"archived_ids": archived}, # available to downstream steps
summary=f"Archived {len(archived)} thread(s).",
)A destructive tool can also produce output. The platform pauses for confirmation before running it, then executes on accept, then carries its output forward to the next steps.
Confirmation in chains
When any chain step is destructive, the platform:
- Runs the read-only steps first.
- Reaches the destructive step → pauses and shows a single consolidated confirmation card covering every destructive action remaining in the chain.
- On accept, runs those steps with exactly the arguments the user saw — the model does not get a second chance to change them.
User: "summarize my unread and delete the spam"
↓
Step 1 mail.list_unread (read) → 12 unread, 4 are spam
↓
Step 2 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 in chains
Guarantees
Prior output is read-only
A downstream step receives prior output through its params; it can't reach back and mutate an earlier step.
Single-user scope
A chain runs entirely within one user's scope — one step's data never crosses to another user.
Validated outputs
When a producer declares a return shape, its output is validated against it before being carried forward.
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.