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.
This recipe shows an extension that calls an LLM itself (beyond the conversational layer around your handlers) — for content generation inside the handler. If the user has configured BYOLLM, the call runs through their provider automatically; otherwise it uses the platform LLM. Either way you write the same code.
Use case
A "rewrite my message" tool — the user pastes text, picks a tone, and the extension rewrites it.
from pydantic import BaseModel, Field
from typing import Literal
class RewriteParams(BaseModel):
text: str = Field(description="The text to rewrite.")
tone: Literal["formal", "casual", "concise", "friendly", "apologetic"] = Field(
description="Target tone.",
)
keep_meaning: bool = Field(
True,
description="Preserve the original meaning. Set false to allow restructuring ideas.",
)from imperal_sdk import ChatExtension, ActionResult, sdl
from .schemas import RewriteParams
TONE = {
"formal": "in a formal, professional tone",
"casual": "in a casual, conversational tone",
"concise": "as concisely as possible while keeping every important detail",
"friendly": "to feel warm and friendly",
"apologetic": "to be apologetic and humble",
}
# The rewritten text is an sdl.Entity: core id/title/kind, plus the Bodied
# facet (content.body) for the rewrite and the AIProvenance facet
# (device.ai_model) recording which model produced it.
class Rewrite(sdl.Entity, sdl.Bodied, sdl.AIProvenance):
pass
@chat.function(
"rewrite",
description="Rewrite a piece of text in a specified tone using the user's configured LLM.",
action_type="read",
data_model=Rewrite,
)
async def rewrite(ctx, params: RewriteParams) -> ActionResult:
instruction = f"Rewrite the following text {TONE[params.tone]}."
if params.keep_meaning:
instruction += " Preserve all factual content; don't add or remove information."
result = await ctx.ai.complete(f"{instruction}\n\n{params.text}")
return ActionResult.success(
Rewrite(
id="rewrite",
title=f"Rewrite ({params.tone})",
body=result.text,
generated_by_ai=True,
ai_model=result.model,
),
summary=result.text,
)ctx.ai.complete(prompt) is the only call you make. The platform decides — per user — whether to route it through their BYOLLM provider or the platform LLM. Your code stays provider-agnostic; there is no separate provider object to reach for and nothing to check.
What you NEVER do
Don't import the anthropic / openai SDK
`ctx.ai.complete(...)` is the supported surface; a direct provider import bypasses BYOLLM routing and isn't allowed.
Don't look for the user's credentials
API keys never reach your extension — not in ctx.user, not anywhere.
Don't loop unbounded
Bound your own multi-call loops; the platform doesn't cap in-handler loops, and cost reviews catch unbounded calls.
Try it in chat
"rewrite this casually: 'Per our previous conversation, attached please find the document for your perusal.'"
The classifier picks rewrite(text=..., tone="casual"). Your handler calls ctx.ai.complete(...) and returns something like "Hey — here's the doc you wanted, take a look when you get a chance."
Billing
If the user is on BYOLLM, the call carries no platform fee — the platform resolves this automatically; your extension doesn't check or set anything. See BYOLLM Pricing.
Variations
class RewriteParams(BaseModel):
text: str = Field(description="Text to rewrite.")
tone: Literal["formal", "casual"] = Field(description="Tone.")
n_variations: int = Field(3, description="Number of rewrites to generate.")
# A list result is a concrete sdl.EntityList[Rewrite].
class RewriteList(sdl.EntityList[Rewrite]):
pass
@chat.function(
"rewrite_multiple",
description="Generate several rewrites of the same text.",
action_type="read",
data_model=RewriteList,
)
async def rewrite_multiple(ctx, params: RewriteParams) -> ActionResult:
items = []
for i in range(params.n_variations):
r = await ctx.ai.complete(
f"Rewrite the following {params.tone}, variation {i + 1}:\n\n{params.text}"
)
items.append(
Rewrite(
id=f"variation-{i + 1}",
title=f"Variation {i + 1}",
body=r.text,
generated_by_ai=True,
ai_model=r.model,
)
)
return ActionResult.success(
RewriteList(items=items, total=len(items), has_more=False),
summary=f"{len(items)} rewrite(s).",
)Ask for JSON in the prompt, parse the response text, and map each item onto an sdl.Entity. An action item is a task — use the Assignable facet (people.assignee) and Schedulable facet (time.due_at):
import json
class ActionItem(sdl.Entity, sdl.Assignable, sdl.Schedulable):
pass
class ActionItemList(sdl.EntityList[ActionItem]):
pass
@chat.function(
"extract_action_items",
description="Extract action items from a block of text.",
action_type="read",
data_model=ActionItemList,
)
async def extract_action_items(ctx, params: RewriteParams) -> ActionResult:
result = await ctx.ai.complete(
"Extract action items from the text below as JSON with the shape "
'{"items": [{"owner": "", "deadline": "", "task": ""}]}. '
"Output only the JSON.\n\n" + params.text
)
try:
raw = json.loads(result.text)["items"]
except (ValueError, KeyError):
raw = []
items = [
ActionItem(
id=str(i),
title=row.get("task", ""),
assignee=row.get("owner"),
due_at=row.get("deadline"),
)
for i, row in enumerate(raw)
]
return ActionResult.success(
ActionItemList(items=items, total=len(items), has_more=False),
summary=f"{len(items)} action item(s).",
)Where to next
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.
Master-detail panel
Build a master-detail panel in Imperal Cloud: a list on the left drives an editor in the center via ui.Call, with the newest item auto-opened on first load.