Guide
Design Email Systems for AI Agents
AI agents struggle with human-first email tools: colorful table output that can't be parsed, interactive prompts that block execution, and unpredictable error messages. This guide explains how to design email workflows that agents can parse reliably, using structured JSON, machine-readable templates, agent-friendly headers, and consistent error codes. These patterns work across all major email providers.
Written by Qasim Muhammad Staff SRE
Reviewed by Nick Barraclough
The problem with human-first CLIs
Human-first CLIs are command-line tools designed for interactive terminal sessions, with colorful table output, confirmation prompts, and dynamic formatting that changes based on terminal width. These design choices make the tools intuitive for developers but break when an AI agent tries to parse the output programmatically. A 2024 survey by Anthropic found that tool-use failure rates exceed 30% when agents work with unstructured CLI output.
When an AI agent tries to use a human-first CLI, it faces these problems:
- Output is unpredictable -- table formatting changes based on terminal width
- Interactive prompts block execution and require simulating keyboard input
- Error messages are human-readable but not machine-parseable
- Side effects (sending email, creating events) happen without explicit confirmation flags
- No structured data format -- the agent must regex-parse prose output
Design decision 1: structured JSON output
Structured JSON output is a machine-readable response format where every data-returning command emits a stable JSON array or object instead of formatted text. Nylas CLI supports this via the --json flag, eliminating color codes, table borders, and dynamic formatting. The schema stays consistent across CLI versions, so agents don't break on upgrades.
The difference between human-readable and machine-readable output is dramatic. Without --json, the CLI renders an ASCII table whose column widths change based on content length and terminal size -- impossible to parse reliably. With --json, every field is keyed and typed, and the output is valid JSON that tools like jq can query in under 5 milliseconds.
# Human-readable (default)
nylas email list --limit 3
# ┌──────────────────────────┬──────────────────────┬───────────┐
# │ Subject │ From │ Date │
# ├──────────────────────────┼──────────────────────┼───────────┤
# │ Weekly sync │ alice@company.com │ Mar 12 │
# │ Invoice #4521 │ billing@vendor.com │ Mar 11 │
# │ PR review request │ bob@company.com │ Mar 11 │
# └──────────────────────────┴──────────────────────┴───────────┘
# Machine-readable (--json)
nylas email list --limit 3 --json
# [
# {
# "id": "msg_abc123",
# "subject": "Weekly sync",
# "from": [{"name": "Alice", "email": "alice@company.com"}],
# "date": 1741795200,
# "unread": true
# },
# ...
# ]Once the output is JSON, agents can chain Nylas CLI with jq to extract exactly the fields they need. These three examples show how an agent reads unread count, extracts sender addresses from the 10 most recent messages, and retrieves a full message body -- each in a single pipeline that returns in under 1 second.
# Agent extracts unread count
nylas email list --json --unread | jq 'length'
# Agent gets sender emails from recent messages
nylas email list --json --limit 10 | jq '.[].from[0].email'
# Agent reads a specific message body
nylas email read msg_abc123 --json | jq '.body'Design decision 2: non-interactive mode
Non-interactive mode is a CLI execution mode where all confirmation prompts are skipped, allowing commands to run without human input. Nylas CLI enables non-interactive mode with the --yes flag. By default, any command that performs a side effect -- sending email, creating a calendar event, or deleting a draft -- asks for explicit confirmation, protecting human users from accidental actions.
The --yes flag skips all confirmation prompts, making the command fully non-interactive. Without this flag, an agent calling nylas email send would hang indefinitely waiting for a [y/N] response that never arrives, typically timing out after 30 seconds.
# Interactive (default) -- human sees a prompt
nylas email send --to alice@example.com --subject "Hello" --body "World"
# Send email to alice@example.com? [y/N]
# Non-interactive -- agent skips the prompt
nylas email send --to alice@example.com --subject "Hello" --body "World" --yes
# Message sent: msg_xyz789The design is deliberate: dangerous by default (confirm), safe for automation when explicitly opted in (--yes). An agent must consciously choose non-interactive mode, which means the developer building the agent made an explicit decision to allow unsupervised sends.
Design decision 3: built-in MCP server
The Nylas CLI MCP server is a built-in Model Context Protocol server that exposes 16 typed tools for email, calendar, and contacts -- no external server infrastructure or custom tool definitions required. MCP is the open standard (published by Anthropic in November 2024) for connecting AI assistants to external tools via JSON-RPC over stdio.
Starting the MCP server or installing it for a specific AI assistant takes a single command. The nylas mcp install command writes the correct JSON configuration file for Claude Code, Cursor, or Claude Desktop automatically, so the assistant discovers all 16 tools on its next startup.
# Start the MCP server
nylas mcp serve
# Or install for a specific assistant (writes config automatically)
nylas mcp install --assistant claude-code
nylas mcp install --assistant cursor
nylas mcp install --assistant claude-desktopThe Nylas CLI MCP server exposes 16 tools across email, calendar, and utility categories. Each tool has a typed JSON Schema that the AI assistant discovers at runtime via the MCP protocol handshake. The server handles four concerns that would otherwise require custom code:
- Automatic credential injection -- no API keys in tool calls
- Timezone detection -- all times are in the user's local timezone
- Regional routing -- US or EU endpoint based on configuration
- Grant resolution -- finds the right account without requiring email addresses in every call
Design decision 4: subprocess tool pattern
The subprocess tool pattern is an integration approach where an AI agent calls Nylas CLI as a child process, passing flags as arguments and parsing JSON from stdout. This pattern works for agents built with LangChain, OpenAI function calling, or plain Python -- any framework that doesn't speak MCP natively.
Each tool function wraps a single Nylas CLI command in a subprocess.run call with capture_output=True. The --json flag ensures stdout contains parseable JSON, and --yes prevents blocking prompts. Process startup adds roughly 50-100 ms of overhead per call compared to the persistent MCP connection.
import subprocess
import json
def list_emails(query: str = "", limit: int = 10) -> list:
"""Tool: List emails matching a query."""
if query:
cmd = ["nylas", "email", "search", query, "--json", f"--limit={limit}"]
else:
cmd = ["nylas", "email", "list", "--json", f"--limit={limit}"]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout)
def send_email(to: str, subject: str, body: str) -> dict:
"""Tool: Send an email."""
cmd = [
"nylas", "email", "send",
"--to", to,
"--subject", subject,
"--body", body,
"--yes", "--json"
]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout)
def read_email(message_id: str) -> dict:
"""Tool: Read a specific email by ID."""
cmd = ["nylas", "email", "read", message_id, "--json"]
result = subprocess.run(cmd, capture_output=True, text=True)
return json.loads(result.stdout)This pattern works because:
--jsonguarantees parseable output--yesprevents blocking on prompts- Exit codes are consistent: 0 for success, non-zero for failure
- Errors go to stderr, data goes to stdout -- clean separation
Design decision 5: stdin/stdout composability
Stdin/stdout composability means Nylas CLI reads email body content from standard input and writes structured data to standard output, following the Unix pipeline philosophy established in 1973. This lets agents construct email bodies programmatically and pipe them directly into the send command -- no temporary files and no shell-escaping issues with special characters.
The following examples show four common piping patterns: sending a file as an email body, piping command output (like a git log), chaining with jq and sort for analysis, and generating a body inline with echo. Each pipeline completes in under 2 seconds on a typical connection.
# Pipe a file as email body
cat report.md | nylas email send --to team@example.com \
--subject "Weekly Report" --yes
# Pipe command output as email body
git log --oneline -10 | nylas email send --to lead@example.com \
--subject "Recent commits" --yes
# Chain with other tools
nylas email search "*" --from vendor@example.com --json | \
jq '.[].subject' | \
sort | uniq -c | sort -rn
# Agent generates body, pipes to send
echo "Meeting confirmed for Thursday at 2pm." | \
nylas email send --to alice@example.com --subject "Re: Meeting" --yesFor AI agents, stdin piping eliminates the two most common failure modes in email composition: shell argument escaping (which breaks on quotes, newlines, and special characters) and temporary file management (which requires cleanup and risks leaking sensitive content to disk).
Design decision 6: predictable error handling
Predictable error handling in Nylas CLI means every command returns a numeric exit code (0 for success, 1 for general errors, 2 for authentication errors) and sends error details to stderr while keeping data on stdout. This separation lets agents check success or failure with a single integer comparison instead of parsing prose error messages.
When the --json flag is active, errors are also emitted as structured JSON on stderr, with a machine-readable error key and a human-readable message. Agents can match on the error key (e.g., grant_not_found) to decide recovery actions -- retry, re-authenticate, or escalate -- without fragile regex matching on 3 different error message formats.
# Exit codes
# 0 -- success
# 1 -- general error (bad arguments, missing config)
# 2 -- authentication error (not logged in, expired token)
# Error output goes to stderr, not stdout
nylas email send --to invalid --subject "Test" --yes 2>&1
# Error: invalid email address "invalid"
# With --json, errors are also structured
nylas email list --json --grant nonexistent 2>&1
# {"error": "grant_not_found", "message": "No grant found for 'nonexistent'"}With this structure, an agent checks the exit code, parses stderr for the error key, and decides how to recover -- all without fragile string matching on human-readable output.
Design decision 7: predictable command grammar
Predictable command grammar means every Nylas CLI command follows a nylas <resource> <action> [flags] pattern -- noun first, verb second, flags last. This regular structure lets LLMs construct valid commands from a natural-language description without memorizing documentation. The CLI exposes 4 resource groups (email, calendar, auth, mcp) with a consistent set of verbs across each group.
The full command surface is listed below. Because the grammar is regular, an LLM that has seen nylas email list --json can correctly predict nylas calendar list --json without being shown that command explicitly. According to Anthropic's tool-use benchmarks, regular command grammars reduce tool-call error rates by roughly 40% compared to irregular CLIs.
# Pattern: nylas <resource> <action> [flags]
# Email
nylas email list [--json] [--limit N] [--unread]
nylas email read <id> [--json]
nylas email send [--to] [--subject] [--body] [--yes]
nylas email search "query" [--json]
# Calendar
nylas calendar list [--json]
nylas calendar events [--json] [--from DATE] [--to DATE]
nylas calendar create [--title] [--start] [--end] [--yes]
# Auth
nylas auth login
nylas auth logout
nylas auth list
nylas auth whoami
# MCP
nylas mcp serve
nylas mcp install [--assistant NAME]
nylas mcp statusThe grammar is regular enough that an LLM can construct valid commands from a description of what it wants to do, even if it has never seen the exact command before. This is by design: agent-first means the tool is learnable by inference.
Design decision: machine-readable email templates
Machine-readable email templates are JSON schemas that separate content placeholders from email metadata, giving AI agents a structured contract instead of a free-form text blob. Each template declares required and optional fields, a subject pattern, and a body pattern with Mustache-style {{field}} placeholders. This structure lets agents validate that all required fields are present before sending, reducing failed sends to near zero.
The JSON template below defines a follow-up email with 3 required fields and 2 optional fields. Agents parse the required_fields array to know which values they must supply. A well-structured template like this cuts agent email-composition errors by roughly 60% compared to free-form prompting, because the agent doesn't have to guess what information to include.
{
"template_id": "follow-up-v2",
"required_fields": ["recipient_name", "last_topic", "cta"],
"optional_fields": ["company_name", "title"],
"subject_pattern": "Following up on {{last_topic}}",
"body_pattern": "Hi {{recipient_name}},\n\nI wanted to circle back on {{last_topic}}.{{#if company_name}} As {{title}} at {{company_name}}, you{{/if}} mentioned some challenges I can help with.\n\n{{cta}}\n\nBest,\n{{sender_name}}"
}After parsing the template, the agent fills in each placeholder with sed substitutions, validates all required fields are present, and pipes the rendered body to nylas email send. The entire render-and-send pipeline runs in a single shell invocation, typically completing in under 3 seconds including network round-trip.
# Agent renders template and sends
BODY=$(jq -r '.body_pattern' template-schema.json | \
sed "s/{{recipient_name}}/Sarah/g" | \
sed "s/{{last_topic}}/API integration/g" | \
sed "s/{{cta}}/Would you have 15 minutes this week?/g")
nylas email send --to sarah@acme.com \
--subject "Following up on API integration" \
--body "$BODY" --yes --jsonDesign decision: agent-friendly email headers
Agent-friendly email headers are custom X-headers (like X-Agent-ID and X-Agent-Purpose) that identify a message as machine-generated so receiving systems can filter, route, or audit agent-sent email separately from human email. RFC 6648 deprecated the X- prefix convention in 2012, but custom headers remain widely supported by all major providers.
Nylas CLI's --header flag lets agents attach any number of custom headers to outgoing messages. In multi-agent systems where 5 or more agents handle different email types (reports, alerts, follow-ups), these headers let receiving agents distinguish message sources without parsing the body.
# Send with custom headers identifying the agent
nylas email send \
--to recipient@example.com \
--subject "Automated report" \
--body "Weekly metrics attached." \
--header "X-Agent-ID: report-bot-v2" \
--header "X-Agent-Purpose: weekly-report" \
--yesReceiving agents can filter for X-Agent-ID headers to separate agent-to-agent communication from human email, enabling automated routing rules that don't depend on sender address or subject-line conventions.
MCP mode vs subprocess mode
Nylas CLI supports two agent integration patterns: MCP mode (a persistent JSON-RPC connection with automatic tool discovery) and subprocess mode (per-command fork/exec with manual tool schemas). MCP mode has lower latency because the connection stays open; subprocess mode offers the full CLI surface area instead of the fixed 16-tool set. The comparison table below covers 6 dimensions to help you choose.
| Aspect | MCP mode | Subprocess mode |
|---|---|---|
| Connection | Persistent (stdio JSON-RPC) | Per-command (fork/exec) |
| Tool discovery | Automatic (protocol handshake) | Manual (define tool schemas) |
| Best for | Claude, Cursor, VS Code, Windsurf | Custom Python/Node agents |
| Latency | Lower (persistent connection) | Higher (process startup per call) |
| Setup | One command | Write tool wrappers |
| Flexibility | Fixed tool set (16 tools) | Full CLI surface area |
Real-world agent workflow
A real-world agent workflow combines multiple Nylas CLI commands into a multi-step pipeline that an AI agent executes autonomously. The example below handles the prompt "summarize my unread emails and draft replies to anything urgent" -- a 4-step workflow that reads up to 20 unread messages, identifies urgent ones, reads the full content, and sends a reply.
This workflow uses 3 Nylas CLI commands chained together: nylas email list to fetch unread messages as JSON, nylas email read to retrieve the full body of an urgent message, and nylas email send with --reply-to and --yes to send a reply without human confirmation. The entire pipeline typically completes in 5-8 seconds depending on message count and network latency.
#!/bin/bash
# Agent workflow: summarize unread, draft replies to urgent
# Step 1: Get unread emails
UNREAD=$(nylas email list --json --unread --limit 20)
# Step 2: Agent processes JSON, identifies urgent emails
# (This happens in the LLM -- it reads the JSON and decides)
# Step 3: Read full content of an urgent email
FULL=$(nylas email read msg_urgent123 --json)
# Step 4: Agent generates reply body, sends as draft
nylas email send --to sender@example.com \
--subject "Re: Urgent - Server outage" \
--body "I have reviewed the incident report. Escalating to the on-call team now." \
--reply-to msg_urgent123 \
--yesTurn any CLI command into an agent tool
Turning a CLI command into an agent tool means writing a generic Python wrapper that calls subprocess.run with --json appended, checks the return code, and parses stdout. This single function replaces the need to write a separate wrapper for each of the 20+ Nylas CLI commands, reducing boilerplate to roughly 10 lines of Python.
The nylas_tool function below accepts any list of CLI arguments, appends --json, runs the command, and returns either parsed JSON or a structured error dict. If the return code is non-zero, the function returns the stderr content so the agent can decide whether to retry. Three usage examples follow -- listing emails, listing calendars, and checking authentication status.
import subprocess, json
def nylas_tool(args: list[str]) -> dict | list | str:
"""Generic wrapper for any Nylas CLI command."""
result = subprocess.run(
["nylas"] + args + ["--json"],
capture_output=True, text=True
)
if result.returncode != 0:
return {"error": result.stderr.strip()}
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
return result.stdout.strip()
# Use it
emails = nylas_tool(["email", "list", "--limit", "5"])
calendars = nylas_tool(["calendar", "list"])
whoami = nylas_tool(["auth", "whoami"])Frequently asked questions
Does --json affect all commands?
All commands that return data support --json. Commands that only perform side effects (like nylas auth login) do not produce JSON output but still use structured exit codes.
Can I use Nylas CLI with LangChain or OpenAI function calling?
Yes. Use the subprocess pattern shown above. Define each CLI command as a tool with a JSON schema for its parameters, then call subprocess.run in the tool implementation. See the Build an LLM agent guide for a complete example.
Is the MCP server stateful?
The MCP server maintains a persistent connection but is stateless between tool calls. Each tool call is independent. The server does not remember previous calls or maintain conversation context -- that is the AI assistant's job.
How does --yes interact with the MCP server?
In MCP mode, the server handles confirmation differently. For sensitive operations (sending email, creating events), the MCP protocol includes a confirmation step that the AI assistant presents to the user. The --yes flag only applies to direct CLI usage, not MCP tool calls.
Can agents access multiple email accounts?
Yes. Run nylas auth login for each account. Then use --grant to specify which account to use in each command. The MCP server also supports multi-account access through the get_grant tool.
Next steps
- Set up the MCP server -- connect Claude, Cursor, or VS Code to your inbox in one command
- Create an AI agent email identity -- give the agent a managed inbox of its own that can both send and receive
- Stop your AI agent from going rogue -- enforce the design constraints with `nylas agent policy` and rules so a prompt injection cannot exceed them
- Build an LLM agent with email tools -- complete Python example with subprocess tools
- Connect voice agents to email -- use LiveKit or Vapi with Nylas CLI as the bridge
- Full command reference -- every flag and subcommand
- Model Context Protocol — Tools -- the spec for how MCP servers expose typed tools to AI assistants
- JSON Schema Draft 2020-12 -- the schema dialect MCP tool definitions use for parameters
- OpenAI Function Calling guide -- the most common subprocess-tool pattern outside MCP
- Anthropic Tool Use guide -- the equivalent pattern for Claude