Email composer
Compose and rewrite professional emails as typed drafts using structured output.
This agent turns a short brief into a polished, send-ready email and can rewrite an existing draft in a different tone. The email best-practices live in the agent's instructions, and every reply is returned as a typed EmailDraft (subject, body, tone, word count) via output=, so you get structured data back instead of a wall of text you have to parse.
It is a focused, single-file demonstration of structured output: pass a Pydantic model as output, and result.output comes back as an instance of that model.
The agent
The whole thing is one Pydantic model plus one Agent. There are no tools and no external services — composing and rewriting are both just prompts against the same agent.
from pydantic import BaseModel, Field
from agentroute import Agent
class EmailDraft(BaseModel):
"""A complete, send-ready email."""
subject: str = Field(description="A clear, specific subject line under 60 characters.")
body: str = Field(description="The full email body, including greeting and sign-off.")
tone: str = Field(description="The tone used, e.g. 'formal', 'friendly', 'concise'.")
word_count: int = Field(description="Number of words in the body.")
composer = Agent(
name="email-composer",
model="claude-sonnet-4",
instructions=(
"You write professional emails. Follow these rules:\n"
"- Open with an appropriate greeting and close with a clear sign-off.\n"
"- Lead with the ask or the key point in the first sentence.\n"
"- Keep paragraphs short; prefer plain language over jargon.\n"
"- Be specific about any dates, numbers, or next steps.\n"
"- Match the requested tone exactly.\n"
"- Never invent facts that were not given in the brief."
),
output=EmailDraft,
)
def compose(brief: str) -> EmailDraft:
"""Compose a fresh email from a brief."""
result = composer.run(brief)
return result.output
def rewrite(draft: str, tone: str) -> EmailDraft:
"""Rewrite an existing email in a different tone."""
result = composer.run(
f"Rewrite the following email in a {tone} tone. "
f"Keep the same intent and facts.\n\n{draft}"
)
return result.output
if __name__ == "__main__":
# 1. Compose from a brief.
draft = compose(
"Email to my manager Dana asking to move our 1:1 from Thursday "
"3pm to Friday 10am because of a client call. Friendly but professional."
)
print(f"Subject: {draft.subject}")
print(f"Tone: {draft.tone} ({draft.word_count} words)\n")
print(draft.body)
# 2. Rewrite the same email in a terser tone.
terse = rewrite(draft.body, tone="concise")
print("\n--- rewritten (concise) ---\n")
print(terse.body)Because output=EmailDraft, the model is asked to emit data matching the schema and AgentRoute validates it for you. result.output is a real EmailDraft instance — draft.subject, draft.word_count, and the rest are typed attributes, not dictionary lookups.
The Field(description=...) text is fed to the model as part of the schema. Treat it as a place to encode constraints ("under 60 characters", "including greeting and sign-off") — it steers the output as much as the top-level instructions do.
Run it
export AGENTROUTE_API_KEY=sk-...
python email_composer.pyOne OpenRouter key works for every model string, so model="claude-sonnet-4" resolves with the same AGENTROUTE_API_KEY. See models for how model strings are resolved.
How the rewrite works
There is no separate "rewrite" mode in the SDK — rewriting is just composing with a different prompt. The agent already knows the email rules from its instructions, so the rewrite call only has to supply the source draft and the target tone:
result = composer.run(
f"Rewrite the following email in a {tone} tone. "
f"Keep the same intent and facts.\n\n{draft}"
)
new_draft = result.output # EmailDraft, retyped with the new tone + word_countEach run is independent — the composer holds no conversation state between calls. If you wanted the agent to remember earlier drafts across a session, you would attach memory or pass prior turns explicitly; for a stateless compose/rewrite tool, independent calls are simpler and cheaper.
Tightening the output
If you want a hard guarantee — say, the body must never exceed 150 words — add an output validator. It runs after the model produces a draft and can raise Retry to send the model back for another attempt, bounded by retries:
from agentroute import Agent, Context, Retry
composer = Agent(
name="email-composer",
model="claude-sonnet-4",
instructions="...", # same rules as above
output=EmailDraft,
retries=2,
)
@composer.output_validator
def enforce_length(ctx: Context, output: EmailDraft) -> EmailDraft:
if output.word_count > 150:
raise Retry(f"Body is {output.word_count} words; keep it under 150.")
return outputThe validator receives the typed EmailDraft and must return one (optionally modified). Retry is a control-flow signal, not an error — it is caught by the run loop and turned into another model attempt. See errors and retries for the full retry model.
Concepts used
Pass a Pydantic model as output= and read result.output as a typed instance.
@agent.output_validator plus Retry to enforce constraints the prompt cannot guarantee.
The Agent class: name, model, instructions, output, retries, and run.
What run returns, including result.output for structured runs.