Recipe — send an email
Send an email from a Webbee extension — a complete, copy-paste send_email tool with a Pydantic schema, an async handler, and tests, federal-clean and runnable.
A real, working email-send tool. Copy, paste, ship.
from pydantic import BaseModel, Field
from typing import Literal
class SendEmailParams(BaseModel):
to: list[str] = Field(
description="Recipient email addresses. Must be valid RFC 5321 format. At least one required.",
)
subject: str = Field(
description="Subject line — concise, ≤120 chars, no leading 'Re:' (web-kernel handles for replies).",
)
body: str = Field(
description="Plain-text or HTML body. Write the FULL body — never placeholders like '<email body>'.",
)
cc: list[str] | None = Field(None, description="Optional CC.")
bcc: list[str] | None = Field(None, description="Optional BCC.")
importance: Literal["low", "normal", "high"] = Field("normal", description="Email importance.")
reply_to_thread_id: str | None = Field(
None,
description="Optional thread UUID — set ONLY when replying to an existing thread.",
)from imperal_sdk import ChatExtension, ActionResult, sdl
from app import ext # the Extension declared in app.py
from .schemas import SendEmailParams
# The sent message is an sdl.Entity: core id/title/kind, plus the
# Correspondents facet (people.recipients_to) and MessageState facet
# (comm.direction / comm.sent_at). All handlers — including write/destructive
# ones — return ActionResult wrapping a typed entity, never a bare dict.
class SentMessage(sdl.Entity, sdl.Correspondents, sdl.MessageState):
thread_id: str | None = None
# A ChatExtension instance is what provides the @chat.function decorator.
chat = ChatExtension(ext, "mail_sender", description="Send email through the user's connected mail provider in plain language.")
@chat.function(
"send_email",
description="Send an email message via the user's connected mail account.",
action_type="write",
effects=["email.send"],
data_model=SentMessage,
)
async def send_email(ctx, params: SendEmailParams) -> ActionResult:
payload = params.model_dump(exclude_none=True)
sent = await ctx.http.post("/mail/send", json=payload)
return ActionResult.success(
SentMessage(
id=sent["id"],
title=params.subject,
recipients_to=params.to,
direction="outbound",
thread_id=sent.get("thread_id"),
),
summary=f"Sent to {', '.join(params.to)}.",
)from imperal_sdk import Extension
from . import handlers_chat # noqa: F401
ext = Extension(
"mail-sender",
display_name="Mail Sender",
description="Send email through the user's connected mail provider in plain language.",
icon="mail",
actions_explicit=True,
)Test it
imperal test takes no flags — it simply runs your tests/ directory under pytest. Write a unit test that calls the handler with a MockContext:
import pytest
from imperal_sdk.testing import MockContext
from handlers_chat import send_email
from schemas import SendEmailParams
@pytest.mark.asyncio
async def test_send_email() -> None:
ctx = MockContext(user_id="user_test_01")
result = await send_email(
ctx,
SendEmailParams(
to=["sarah@example.com"],
subject="Q3 plan",
body="Hey Sarah,\n\nAttached is the Q3 plan. Let me know what you think.\n\n—Alex",
),
)
assert result.ok
assert result.data.id # the SentMessage entity idThen run the suite:
imperal testTry it in chat
"email sarah at sarah@example.com about the q3 plan — say it's attached and ask for her thoughts"
The classifier picks send_email, fills params from your message + her email, the SDK validates, your handler runs.
Variations
Add to schema:
attachments: list[dict] | None = Field(
None,
description="Optional list of {file_id, filename}. file_id from previous list_files / search_files calls.",
)The federal way: let the LLM ask for a file by id (from skeleton or a prior tool call), not raw bytes.
Set reply_to_thread_id from the user's prior context. The classifier will fill it from the chain or a list_unread step.
cc: list[str] | None = Field(
None,
description="Optional CC. Use this when the user asks to 'loop in' or 'cc' someone.",
)The LLM will fill this from natural language phrasing.
What you should NOT do
Don't accept raw SMTP credentials in args
Use ctx.secrets (or [BYOLLM](/en/reference/glossary/)-style provider config). Args go to LLMs and audit logs — never raw secrets.
Don't bypass the audit chokepoint
ctx.http auto-audits. Custom HTTP clients (requests, httpx) skip it — discouraged by convention even though no federal validator hard-bans them.
Don't mark this as 'read'
Sending email is a side-effect. action_type='write' is correct.
Where to next
Web Search
Web Search is a built-in Imperal Cloud capability that lets Webbee research the live web — find pages for a query and read them into clean Markdown, no setup.
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.