Skip to content
Skip to main content

Build an LLM agent with email & calendar tools

If you’re rolling a custom agent — your own loop, not Claude Code or Cursor — you’ve got two options for giving it email access. Option A: implement Gmail OAuth, Microsoft Graph OAuth, IMAP, refresh-token plumbing, attachment handling. That’s ~300 lines of boilerplate before the agent gets to do anything useful. Option B: shell out to nylas and let the CLI handle all of it.

This recipe is option B. We wrap nylas email list, nylas email send, and a couple of calendar commands as subprocess-callable Python functions, define the matching tool schemas, and plug them into a standard tool-calling loop.

import json, subprocess
def list_emails(limit: int = 10, unread_only: bool = False) -> str:
"""List recent emails on the active grant."""
cmd = ["nylas", "email", "list", "--limit", str(limit), "--json"]
if unread_only:
cmd.append("--unread")
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
return out.stdout if out.returncode == 0 else f"Error: {out.stderr}"
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email. --yes is required so we don't hang on prompts."""
out = subprocess.run(
["nylas", "email", "send", "--to", to, "--subject", subject,
"--body", body, "--yes", "--json"],
capture_output=True, text=True, timeout=30,
)
if out.returncode != 0:
return f"Error: {out.stderr}"
return "Email sent."
def list_events(days: int = 7) -> str:
"""List upcoming events on the active calendar."""
out = subprocess.run(
["nylas", "calendar", "events", "list", "--days", str(days), "--json"],
capture_output=True, text=True, timeout=30,
)
return out.stdout if out.returncode == 0 else f"Error: {out.stderr}"

Two non-obvious flags carry the load:

  • --yes is critical. Without it, send commands wait for a “send this?” prompt that an agent loop will never answer.
  • --json gives the LLM something it can parse. Plain output works for humans; structured output works for tools.

OpenAI-style schemas (Anthropic’s are nearly identical):

tools = [
{
"type": "function",
"function": {
"name": "list_emails",
"description": "List recent emails from the user's inbox. Returns JSON.",
"parameters": {
"type": "object",
"properties": {
"limit": {"type": "integer", "default": 10},
"unread_only": {"type": "boolean", "default": False},
},
},
},
},
{
"type": "function",
"function": {
"name": "send_email",
"description": "Send an email. Confirm recipient, subject, and body with the user before calling.",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
"required": ["to", "subject", "body"],
},
},
},
{
"type": "function",
"function": {
"name": "list_events",
"description": "List upcoming calendar events.",
"parameters": {
"type": "object",
"properties": {"days": {"type": "integer", "default": 7}},
},
},
},
]
DISPATCH = {
"list_emails": list_emails,
"send_email": send_email,
"list_events": list_events,
}
def run_tool(tool_call) -> dict:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments or "{}")
return {
"role": "tool",
"tool_call_id": tool_call.id,
"content": DISPATCH[name](**args),
}

The standard agent loop doesn’t change shape:

messages = [{"role": "user", "content": "Did Ada respond about the contract?"}]
while True:
resp = client.chat.completions.create(
model="...",
messages=messages,
tools=tools,
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
print(msg.content)
break
for call in msg.tool_calls:
messages.append(run_tool(call))

The LLM may issue several tool calls before producing a final answer — the loop just keeps running until it returns a message with no tool calls.

A hand-rolled Gmail OAuth integration is ~300 lines just for token management. Add Microsoft Graph and you’re at 600. Add IMAP fallback and you’re past 1,000. The subprocess approach gives you 16 tools across all six providers in under 50 lines of Python, plus you get OAuth refresh, multi-account switching, and rate-limit handling for free.

  • Output size. nylas email list --limit 100 produces a lot of JSON. Cap limit aggressively in the tool schema (default 10) to keep context manageable.
  • Active grant. The CLI uses whichever grant is current in nylas auth list. For multi-tenant agents, run a per-tenant CLI process or pass --api-key explicitly.
  • Error surface. Subprocess errors come back as strings. The LLM is good at deciding what to do with them (“Looks like the grant expired — should I re-authenticate?”) if you let the stderr through.