Guide

Build a Semantic Router Email Agent

Route email intents to the Nylas CLI with Semantic Router Routes, embedding matches, JSON subprocess calls, and safe draft workflows across six providers.

Written by Pouya Sanooei Software Architect

VerifiedCLI 3.1.20 · Gmail · last tested June 14, 2026

Command references used in this guide: nylas email list, nylas email read, nylas email search, and nylas email drafts create.

How do you give a Semantic Router agent email?

A Semantic Router email agent starts by reading a small batch of messages as JSON, then handing each subject and body to a route layer. The CLI supplies provider-normalized email in 1 command, and Semantic Router decides whether the text looks like invoice, support, spam, or a fallback case.

That split matters because Semantic Router is a decision layer from Aurelio AI, not a full chat loop. Its Route concept stores example utterances, embeds them, and compares new text. Use nylas auth login once during setup; after that, the stored grant lets the same read command work across six providers without provider SDK branches.

The route-then-act pattern is simple: fetch 10 unread messages, classify each message body, and dispatch only the command tied to the winning route. The router just picks the lane before your code runs the allowed action.

How do Semantic Router Routes become an email decision layer?

Semantic Router Routes become useful for email when each route name maps to 1 operational intent. Instead of asking an LLM to label every message, define examples for invoices, customer support, and spam-like mail. The route layer compares embeddings and returns the closest route name for dispatch.

This example reads up to 10 unread messages with nylas email list --json because the router needs real subjects before it can choose a route. Ten is enough for a fast triage batch, and --unread keeps the input focused on work the agent has not already handled. The code then passes subject and body text into Semantic Router's RouteLayer.

import json
import subprocess
from semantic_router import Route
from semantic_router.encoders import OpenAIEncoder
from semantic_router.layer import RouteLayer

invoice = Route(
    name="invoice",
    utterances=[
        "invoice attached for payment",
        "receipt for your subscription",
        "payment reminder for account",
        "billing statement available",
    ],
)
support = Route(
    name="support",
    utterances=[
        "customer cannot log in",
        "help with a failed sync",
        "question about account access",
        "support ticket needs a reply",
    ],
)
spam = Route(
    name="spam",
    utterances=[
        "limited time offer",
        "claim your prize now",
        "unsubscribe from this promotion",
        "cold outreach for backlinks",
    ],
)

router = RouteLayer(encoder=OpenAIEncoder(), routes=[invoice, support, spam])

raw = subprocess.run(
    ["nylas", "email", "list", "--json", "--unread", "--limit", "10"],
    capture_output=True,
    text=True,
    check=True,
).stdout

for email in json.loads(raw):
    text = f"{email.get('subject', '')}\n\n{email.get('body', '')}"
    route = router(text).name
    print(email.get("id"), route, email.get("subject"))

The important design choice is that route names are action names, not prose labels. If invoice wins, search related billing mail. If support wins, prepare a draft. If spam wins, mark the message read or skip it.

How does the agent call email commands after routing?

A routed Semantic Router agent should keep command execution boring: a dispatch table turns each route into 1 subprocess call. That boundary gives you JSON output, shell-level logging, and a short allow-list. The model-like component chooses the route; ordinary code chooses the exact command.

Run nylas email read MESSAGE_ID --json after a route match when the list response does not include enough body detail. The command reads exactly 1 message by its positional ID, and --json keeps the result machine-readable for the next function. The support branch below creates a draft, while invoice and spam run lower-risk actions.

import subprocess

def run_cli(args: list[str]) -> str:
    return subprocess.run(
        ["nylas", *args],
        capture_output=True,
        text=True,
        check=True,
    ).stdout

def act_on_route(route: str, email_id: str, sender: str) -> str:
    if route == "invoice":
        return run_cli(["email", "search", "invoice", "--json", "--limit", "20"])

    if route == "support":
        message = run_cli(["email", "read", email_id, "--json"])
        return run_cli([
            "email", "drafts", "create",
            "--to", sender,
            "--subject", "Re: support request",
            "--body", "Thanks for the details. A teammate will review this shortly.",
            "--reply-to", email_id,
            "--json",
        ])

    if route == "spam":
        return run_cli(["email", "mark", "read", email_id])

    return '{"status":"skipped"}'

You can inspect the same path from a terminal before wiring it into Python. This 2-command check lists unread mail and extracts IDs with jq, then reads one message by ID. It is the fastest way to confirm the CLI returns the fields your route layer expects.

nylas email list --json --unread --limit 5 | jq '.[].id'
nylas email read MESSAGE_ID --json | jq '{id, subject, from}'

Why is route-then-act faster than LLM classification?

Route-then-act is faster because Semantic Router does one embedding comparison instead of asking an LLM to read instructions and produce a label. A route match can finish in milliseconds after startup, while a remote LLM classifier commonly adds 1 to 2 seconds before any email command runs.

The speed difference changes the agent shape. With a chat classifier, every incoming message waits for a generated answer even when the only output is invoice, support, or spam. With Semantic Router, you define examples once, keep them in a local route layer, and only call a heavier model after a route truly needs prose.

That is why the support branch drafts a fixed acknowledgement. The route layer makes a fast binary decision: does this look like a support request or not? A review queue or LLM can improve the reply later, while the first classification step stays cheap, repeatable, and measurable.

How do you tune utterances without retraining the agent?

Semantic Router tuning is mostly utterance design, not model training. Add examples that look like real inbox language, keep route boundaries narrow, and test against a saved set of messages. For 3 routes, start with 5 to 10 utterances each and review mismatches after every deployment.

Use nylas email search --json to build focused test sets before changing route examples. Searching by a phrase such as invoice or refund gives you 20 real messages to classify, which is more useful than invented prompts. The command below exports subjects so you can compare route decisions over time.

nylas email search invoice --json --limit 20 | jq '.[].subject'
nylas email search "login failed" --json --limit 20 | jq '.[].subject'

Keep a small confusion log with message ID, expected route, actual route, and date. If spam examples keep stealing support messages, remove broad phrases like “limited time” and add examples from the exact promotional mail you receive. If invoice and support overlap, split billing support into a fourth route instead of hiding the ambiguity.

What guardrails should the agent have?

A Semantic Router email agent still touches private data, untrusted content, and external communication, which is the lethal trifecta. Routing by embeddings lowers one prompt-injection path, but it does not make email safe. Treat every subject and body as hostile input under OWASP LLM01 guidance for 2025.

Use nylas email drafts create for any outbound support path because a draft creates a reviewable artifact without sending. The command below writes 1 reply draft tied to the original message ID through --reply-to. It deliberately avoids nylas email send, so a bad route cannot deliver mail unattended.

nylas email drafts create \
  --to customer@example.com \
  --subject "Re: support request" \
  --body "Thanks for reaching out. A teammate will review this and follow up." \
  --reply-to MESSAGE_ID \
  --json

Keep write actions behind a route allow-list and log every route decision with timestamp, message ID, selected route, and command name. For autonomous loops, spam can be marked read, support can draft, and invoice can search related mail; none of those 3 paths should send directly. Pair this guide with stop an AI agent going rogue and build a human-in-the-loop email agent before you let a router affect live mailboxes.

Next steps

Use these 5 references to compare Semantic Router with adjacent agent patterns and verify commands before automation.