# Build an LLM agent with email & calendar tools

Source: https://developer.nylas.com/docs/cookbook/cli/llm-agent-with-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 [Nylas CLI](https://cli.nylas.com/) handle all of it.

This recipe is option B. We wrap [`nylas email list`](https://cli.nylas.com/docs/commands/email-list), [`nylas email send`](https://cli.nylas.com/docs/commands/email-send), and a couple of [calendar commands](https://cli.nylas.com/docs/commands/calendar-events-list) as subprocess-callable Python functions, define the matching tool schemas, and plug them into a standard tool-calling loop.

## The wrapper functions

```python


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.

## Tool definitions

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

```python
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

```python
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

The standard agent loop doesn't change shape:

```python
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

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

- **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.

## Next steps

- [Use Nylas MCP with Claude Code](/docs/cookbook/ai/mcp/claude-code/) — for hosts that already speak MCP
- [Connect voice agents to email & calendar](/docs/cookbook/cli/connect-voice-agents/) — same pattern, voice-runtime variant
- [Build a Manus skill for Nylas](/docs/cookbook/cli/manus-skill/)
- [Nylas CLI](https://cli.nylas.com/) — installation and full [command reference](https://cli.nylas.com/docs/commands)