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.
The wrapper functions
Section titled “The wrapper functions”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:
--yesis critical. Without it, send commands wait for a “send this?” prompt that an agent loop will never answer.--jsongives the LLM something it can parse. Plain output works for humans; structured output works for tools.
Tool definitions
Section titled “Tool definitions”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}}, }, }, },]The dispatch helper
Section titled “The dispatch helper”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), }Plug it into your loop
Section titled “Plug it into your loop”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.
Why this beats the alternative
Section titled “Why this beats the alternative”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.
Things to know
Section titled “Things to know”- Output size.
nylas email list --limit 100produces a lot of JSON. Caplimitaggressively 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-keyexplicitly. - 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.
Next steps
Section titled “Next steps”- Use Nylas MCP with Claude Code — for hosts that already speak MCP
- Connect voice agents to email & calendar — same pattern, voice-runtime variant
- Build a Manus skill for Nylas