Guide

Build an Instructor Email Agent

Pipe Nylas CLI email JSON into Instructor response_model= Pydantic schemas for typed inbox triage, validation retries, and safer draft-only reply flows.

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 search, and nylas email drafts create.

Why does Instructor fit unstructured email?

Instructor fits email triage because raw messages mix sender metadata, quoted text, signatures, links, and instructions that may not belong to your app. A plain LLM prompt can answer in 5 different formats across 5 calls. Instructor moves the contract into a Pydantic model, so the output shape is checked before code trusts it.

The read-to-structure loop has 3 steps: fetch email JSON with the CLI, pass the text and metadata into an Instructor call, and receive a typed EmailTriage object. That path avoids regex parsing and prompt-only format rules. It also makes validation errors visible at the boundary where the model response enters Python.

What does Instructor do with response_model=?

Instructor patches an OpenAI-compatible client so each chat completion can name a Pydantic schema with response_model=. The official Instructor docs describe the pattern directly in the call signature: response_model=UserExtract. For email, that concept becomes response_model=EmailTriage.

The practical difference is small but useful: your agent no longer asks the LLM for “valid JSON” and hopes the answer parses. It asks the patched client for 1 model instance with known fields. If priority is outside 1 through 5 or needs_reply is not boolean, validation fails before the agent can branch.

How do you set up the CLI and Python packages?

Set up the Nylas CLI once so the email read command can reuse the saved account. This guide uses 3 Python packages: instructor, openai, and pydantic. You also need an OpenAI API key in the environment before triage.py calls the model.

Run nylas init for interactive setup, then install the Python packages in the virtual environment where you'll run the triage script. The install command below names exactly 3 packages because the script only needs a patched model client, the OpenAI SDK, and Pydantic field validation.

nylas init
pip install instructor openai pydantic

How do you define the EmailTriage model?

The EmailTriage model is the contract between the inbox and the agent loop. It has 4 fields: category, priority, needs_reply, and summary. Pydantic enforces the 1 through 5 priority range, so a drifting model response becomes an error instead of a bad branch.

Define the model before wiring the CLI subprocess or stdin pipeline. The code below is small on purpose: 1 class, 4 fields, and a short summary limit in the prompt later. Keeping the schema narrow makes the Instructor retry loop easier to reason about.

from pydantic import BaseModel, Field

class EmailTriage(BaseModel):
    category: str = Field(description="One label such as billing, support, sales, or personal")
    priority: int = Field(ge=1, le=5, description="1 is lowest, 5 is highest")
    needs_reply: bool
    summary: str = Field(description="One sentence summary for a human reviewer")

How do you run the full read-to-structure pipeline?

The full pipeline reads 10 recent messages with nylas email list --json --limit 10, pipes the JSON into Python, and asks Instructor for one typed triage result per message. The script accepts stdin so the CLI stays the only email integration layer.

Save this script as triage.py and run it with the shell pipeline shown after the code block. It parses the CLI JSON, trims each message to 5 useful fields, calls client.chat.completions.create, and prints one JSON line per triaged email for downstream tools.

import json
import sys

import instructor
from openai import OpenAI
from pydantic import BaseModel, Field


class EmailTriage(BaseModel):
    category: str = Field(description="One label such as billing, support, sales, or personal")
    priority: int = Field(ge=1, le=5, description="1 is lowest, 5 is highest")
    needs_reply: bool
    summary: str = Field(description="One sentence summary for a human reviewer")


client = instructor.from_openai(OpenAI())


def normalize_messages(payload: object) -> list[dict]:
    if isinstance(payload, list):
        messages = payload
    elif isinstance(payload, dict):
        messages = payload.get("messages", [])
    else:
        messages = []

    normalized = []
    for message in messages:
        if not isinstance(message, dict):
            continue
        normalized.append(
            {
                "id": message.get("id"),
                "subject": message.get("subject"),
                "from": message.get("from"),
                "date": message.get("date"),
                "body": message.get("body") or message.get("snippet"),
            }
        )
    return normalized


def triage_email(message: dict) -> EmailTriage:
    return client.chat.completions.create(
        model="gpt-4o-mini",
        response_model=EmailTriage,
        max_retries=3,
        messages=[
            {
                "role": "system",
                "content": (
                    "Triage one email for an instructor. "
                    "Return a short category, priority from 1 to 5, "
                    "whether it needs a reply, and a one sentence summary."
                ),
            },
            {"role": "user", "content": json.dumps(message, ensure_ascii=False)},
        ],
    )


payload = json.load(sys.stdin)
for email in normalize_messages(payload):
    triage = triage_email(email)
    print(json.dumps({"id": email.get("id"), "triage": triage.model_dump()}))

Run the command below after triage.py exists in the current directory. It fetches 10 messages, emits JSON from the CLI, and sends that stream into Python without writing a temporary inbox file. The only email flag used here is --limit 10, so the read stays bounded.

nylas email list --json --limit 10 | python triage.py

How do you read the typed output?

The output is newline-delimited JSON, but each triage object came from a Pydantic instance first. That means priority has already passed the 1 to 5 check and needs_reply is already a boolean. A workflow runner can route on those fields without parsing prose.

The example below shows 2 possible lines from a 10-message run. A high-priority support message can go to a review queue, while a low-priority newsletter can be logged and ignored. The important part is that the shape remains the same across both rows.

{"id":"msg_123","triage":{"category":"support","priority":5,"needs_reply":true,"summary":"A student cannot access the course portal before today's lab."}}
{"id":"msg_456","triage":{"category":"newsletter","priority":1,"needs_reply":false,"summary":"A vendor shared a weekly product update with no action needed."}}

How do validation retries make extraction reliable?

Instructor retries when the LLM response fails the Pydantic model, and the pipeline controls that behavior with max_retries=3. If the model returns priority: 9, omits summary, or writes needs_reply as “maybe,” validation gives the client a concrete error to repair.

Retrying 3 times is usually enough for short email triage because the schema has only 4 fields. For higher-stakes routing, log validation failures with the message ID and send the email to a human queue. Instructor improves extraction shape; it doesn't decide whether an email is safe to act on.

What guardrails should the agent have?

An Instructor email agent still reads untrusted content, so typed output is not permission to send mail. The lethal trifecta is private data + untrusted content + external communication. Email has all 3 when an agent reads an inbox and can also contact people outside your system.

Keep replies behind nylas email drafts create, not direct sending. A draft gives a human reviewer 1 final approval point before external communication happens. Treat prompt injection as OWASP LLM01 (2025): an email can tell the model to ignore rules, but a draft-only path limits the damage. See build a human-in-the-loop email agent and stop an AI agent going rogue for approval and containment patterns.

Next steps

Compare Instructor's typed extraction loop with 3 related guide patterns. Semantic Router puts the decision layer before the action, Atomic Agents uses schema-driven IO, and Vellum models the same workflow as nodes. For every email, calendar, contact, and draft command available to the agent, use the full command reference.