01

From Chatbot to Controlled Workflow

Begin with a small promise: receive an email, decide what it needs, and follow one legal route.

The system is a workflow, not a prompt

A useful email assistant does not merely turn an email into more text. It decides whether work should happen, preserves the information needed while that work proceeds, and selects an allowed action. The smallest honest abstraction is therefore email → decide → act. The language model may help inside the decision or action, but it is not the whole application.

That distinction matters because workflows have visible states and legal transitions. A prompt alone has neither. If a system must ignore newsletters, notify a human about sensitive messages, and answer ordinary requests, those outcomes belong in application control flow. Treating them as prose suggestions makes failures difficult to detect and retries dangerous.

The first implementation can remain deliberately boring:

def process_email(email):
    decision = decide(email)
    return act(decision)

This code is incomplete, but its boundary is correct. Every later component—tools, memory, databases, workers, and evaluations—will refine one of these boxes rather than replace the mental model.

┌───────────┐      ┌───────────┐      ┌───────────┐
│   EMAIL   │ ───▶ │  DECIDE   │ ───▶ │    ACT    │
└───────────┘      └───────────┘      └───────────┘

State is request-scoped working memory

The graph needs an object that moves from node to node. It starts with the incoming email and accumulates decisions, model messages, retrieved context, and tool results. This graph state is short-term working memory: it describes what one execution currently knows.

from typing import Literal, TypedDict

class EmailInput(TypedDict):
    author: str
    to: str
    subject: str
    body: str

class EmailState(TypedDict, total=False):
    email: EmailInput
    messages: list
    classification: Literal["respond", "notify", "ignore"]
    memories: list[str]

State should be explicit enough that a trace can answer: which email entered, which route was selected, and which facts influenced the result? It is not long-term memory. Unless a checkpoint is persisted, this object disappears after the invocation. Long-term memory will later hold what future executions should still know.

Triage produces a machine-readable route

The first real node is triage. Its task is narrow: select respond, notify, or ignore. A free-form paragraph is a poor routing signal because application code would have to interpret it. Structured output turns model judgement into a constrained value that the graph can validate.

from pydantic import BaseModel

class TriageResult(BaseModel):
    classification: Literal["respond", "notify", "ignore"]
    reasoning: str

triage_model = model.with_structured_output(TriageResult)

def triage_node(state: EmailState):
    email = state["email"]
    result = triage_model.invoke(f"""
Classify this email as respond, notify, or ignore.
Author: {email['author']}
Subject: {email['subject']}
Body: {email['body']}
""")
    return {"classification": result.classification}

The reasoning field is useful for audit and evaluation, but the route is the contract. The application can reject an invalid classification before it produces a side effect.

Routing belongs to the graph

Triage answers should work happen? The response agent will later answer what work should happen? Keeping those questions separate avoids spending an expensive agent loop on mail that should end immediately.

from langgraph.graph import END

def route_after_triage(state: EmailState):
    if state["classification"] == "respond":
        return "response_agent"
    if state["classification"] == "notify":
        return "notify_user"
    return END

The model recommends one of three declared paths. It does not invent new nodes, skip approval boundaries, or decide where the application is allowed to go.

                         ┌────────────┐
                    ┌──▶ │    END     │  ignore
                    │    └────────────┘
┌───────────┐   ┌───┴───────┐
│   EMAIL   │──▶│  TRIAGE    │──▶ RESPONSE AGENT
└───────────┘   └────┬───────┘       respond
                     └──────────▶ NOTIFY USER
                                      notify

Assemble the first LangGraph

StateGraph makes the legal topology executable. Placeholder nodes are acceptable at this stage; the important result is a graph that can be tested path by path.

from langgraph.graph import END, START, StateGraph

builder = StateGraph(EmailState)
builder.add_node("triage", triage_node)
builder.add_node("notify_user", notify_user)
builder.add_node("response_agent", response_agent)
builder.add_edge(START, "triage")
builder.add_conditional_edges("triage", route_after_triage)

graph = builder.compile()

A graph-path test can now assert that a newsletter reaches END, an uncertain security message reaches notify_user, and a scheduling request reaches response_agent. That is stronger than checking whether a generated paragraph “sounds right.”

Trace one decision before adding intelligence

Suppose Alice asks, “Can we arrange an architecture review next week?” The ingress adapter normalizes the provider payload into EmailInput. Triage receives only the email and the current rules, returns respond, and the router moves to the response placeholder. Nothing has been sent and no calendar has been changed.

This restraint is a feature. The graph already distinguishes classification from execution, so later tool permissions can be attached to the response path without widening the triage node. It also gives the evaluation suite a stable first seam: route accuracy, critical-message recall, and the unsafe cost of false ignore decisions.

Key takeaways

  • An agentic email assistant is a controlled workflow, not an LLM call with a long prompt.
  • Graph state is temporary, request-specific working memory; long-term memory survives future executions.
  • Structured output converts model judgement into a validated routing contract.
  • Triage decides whether work should happen; the response agent decides what work to perform.
  • Application code owns legal transitions. The model operates inside those boundaries.

Checklist

  • [ ] I can explain why email → decide → act is a better starting model than email → LLM → text.
  • [ ] I can distinguish graph state from long-term memory.
  • [ ] I can name the three triage routes and the terminal behavior of each.
  • [ ] I can explain why routing needs structured output.
  • [ ] I can identify one graph-path evaluation that should block a release.