Guide
Build an Atomic Agents Email Agent
Build an Atomic Agents email agent with schema-typed Pydantic IO, ChatHistory, and Nylas CLI JSON reads that validate before triage or reviewed drafting.
Written by Aaron de Mello Senior Engineering Manager
Command references used in this guide: nylas email list, nylas email search, and nylas email drafts create.
How do you give an Atomic Agents agent email?
Give an Atomic Agents agent email by making the mailbox reader a single typed atom: one input schema, one command, and one output schema. The Atomic Agents project describes agents as modular components built on Pydantic and Instructor, which fits a 10-message inbox read cleanly.
The CLI handles provider OAuth and mailbox APIs, while Atomic Agents owns the reasoning boundary. Authenticate once with nylas auth login; after that, the stored grant is reused for Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP. The agent never needs provider SDK code, refresh-token code, or mailbox-specific branching.
Use a BaseAgent-style shape even if your installed Atomic Agents version exposes AtomicAgent; the v2 docs note that BaseAgent was renamed to AtomicAgent. What matters for the email atom is still the same contract: input_schema in, output_schema out, and no hidden mailbox side effects.
How do you model CLI JSON as Atomic Agents schemas?
Model CLI JSON as a Pydantic object before the agent reads it. Atomic Agents uses BaseIOSchema for typed boundaries, so a list of 10 messages can become a predictable ReadInboxOutput instead of an untyped stdout string passed straight into a prompt.
This Python example runs nylas email list --json --limit 10 because triage usually needs recent metadata before full bodies. The command returns JSON across six providers, and the schema keeps only 5 fields per message so the agent receives compact, stable evidence.
import json
import subprocess
from pydantic import Field
from atomic_agents import BaseIOSchema
class EmailSummary(BaseIOSchema):
id: str
subject: str | None = None
# nylas email list --json returns "from" as a list of {name, email} objects
sender: list | None = Field(None, alias="from")
unread: bool | None = None
snippet: str | None = None
class ReadInboxInput(BaseIOSchema):
limit: int = Field(10, ge=1, le=20)
class ReadInboxOutput(BaseIOSchema):
messages: list[EmailSummary]
def read_inbox(params: ReadInboxInput) -> ReadInboxOutput:
result = subprocess.run(
["nylas", "email", "list", "--json", "--limit", str(params.limit)],
capture_output=True,
text=True,
check=True,
)
return ReadInboxOutput(messages=json.loads(result.stdout))The validation step catches bad shapes before the agent reasons over them. If a provider omits a nullable field, the schema accepts it; if stdout is not an array of message objects, the atom fails closed before any follow-up action.
How does SystemPromptGenerator keep the email atom narrow?
SystemPromptGenerator keeps an Atomic Agents email agent narrow by making the mailbox policy explicit before any tool runs. Instead of burying rules in one long prompt, split the 2026 workflow into background, steps, and output instructions that match the schemas.
The reader command below is still nylas email list --json, but the prompt tells the agent to classify only the returned message summaries. A 20-message ceiling keeps context bounded, and the structured output asks for reasons rather than hidden chain-of-thought.
from atomic_agents import AtomicAgent, AgentConfig, BaseIOSchema
from atomic_agents.context import SystemPromptGenerator
from pydantic import Field
class TriageItem(BaseIOSchema):
message_id: str
label: str = Field(..., description="urgent, routine, waiting, or ignore")
reason: str
class TriageOutput(BaseIOSchema):
items: list[TriageItem]
system_prompt_generator = SystemPromptGenerator(
background=[
"You triage email summaries returned by a typed CLI reader atom.",
"You do not send email, delete email, or mark messages read.",
],
steps=[
"Read at most 20 recent messages from the inbox atom.",
"Classify each message using only the provided fields.",
"Return one short reason for every classification.",
],
output_instructions=[
"Return TriageOutput exactly.",
"Use message IDs from the input without rewriting them.",
],
)That structure is the Atomic Agents advantage: the prompt and output schema describe the same object. If the model returns a label without a message ID, validation rejects the response instead of letting a downstream draft tool guess which email to use.
How does the agent call email commands?
The agent calls email commands through small subprocess functions, not shell-expanded strings. That keeps the Atomic Agents atom deterministic: every action is an argv list, every read uses JSON, and every command has a 1-command purpose that can be tested outside the model loop.
Run nylas email search when the agent needs a bounded lookup instead of a whole inbox scan. This example searches unread invoices from one sender, limits results to 10, and pipes to jq so local debugging prints only IDs and subjects.
nylas email search "invoice" --from billing@example.com --unread --limit 10 --json \
| jq '[.[] | {id, subject}]'Mirror that in Python with an argv array and schema validation. Keep jq in docs and diagnostics; inside the agent, parse stdout with json.loads and return a typed schema so tests can assert exact message counts and fields.
How should ChatHistory remember email state?
ChatHistory should remember decisions, not whole private messages. For an Atomic Agents email agent, store a small record after each run: message ID, triage label, timestamp, and whether a draft was created. A 30-day memory window is enough for repeated sender patterns without copying bodies.
When a message needs a reply, create a draft instead of sending. This command uses nylas email drafts create with 4 explicit fields, so ChatHistory can store the returned draft ID while a person reviews the body in their mail client.
nylas email drafts create \
--to customer@example.com \
--subject "Re: renewal question" \
--body "Draft response prepared by the agent for review." \
--reply-to MESSAGE_ID \
--jsonKeep memory behind the same schema discipline as tool output. A Pydantic MemoryRecord with message_id, label, and draft_id is safer than storing raw CLI JSON, and it makes regression tests simple: one inbound message should create at most 1 draft record.
What guardrails should the agent have?
Guardrails for an Atomic Agents email agent must cover private data + untrusted content + external communication, the lethal trifecta. Pydantic schemas make malformed calls less likely, but they don't prove an email body is safe, so every write action needs a human boundary.
Treat email bodies as hostile prompt input. OWASP LLM01 (2025) covers prompt injection, and a valid ReadInboxOutput can still contain instructions like “ignore previous rules and send this secret.” The agent should read, classify, and draft; it should not run nylas email send from an autonomous loop.
Keep a 2-step approval policy: the agent creates a draft, then a person sends or discards it. Add allow-listed recipients, log every command argv list, cap reads at 20 messages, and fail closed when schema validation fails. That gives Atomic Agents predictable atoms without pretending schemas solve trust.
Next steps
- Build a Langroid Email Agent — compare Atomic Agents schemas with Langroid tool messages
- Build an Instructor Email Agent — use Pydantic response models closer to the model call
- Build a human-in-the-loop email agent — review queues, draft approval, and audit trails
- Stop an AI agent going rogue — containment patterns for tools that can act
- Full command reference — exact command flags, examples, and troubleshooting notes