01

From Chatbot to Controlled Workflow

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

The enterprise problem and today’s slice

Enterprise problem: The reliable unit is a controlled workflow, not a prompt: a mailbox owner needs ordinary requests handled without plausible model prose silently ignoring important mail or triggering unapproved work, because an unexplained route defeats trust and incident review.

Whole-course context: This first slice establishes the controlled email → decide → act envelope; later slices add bounded tools, durable semantic facts, remembered episodes, approved procedures, and production operations.

Today’s slice: Normalize one inbound email, classify it as respond, notify, or ignore, and let application-owned graph transitions select the next state without performing an external side effect.

End-of-day evidence: A route trace records the actor, tenant, provider message reference, validated classification, terminal node, environment, timestamp, and immutable run ID for both allowed and contained paths.

Still unsolved: Email delivery, calendar mutation, approvals, retries, cross-request memory, and production persistence remain deliberately outside this slice.

The system is a workflow, not a prompt

A chatbot can produce a convincing reply while leaving legal transitions invisible, so it cannot prove that newsletters stop or sensitive mail escalates. The smallest complete model is email → decide → act: the language model may help inside a box, but application code owns the workflow.

  • General rule: Model uncertain judgement; encode allowed states and effects in deterministic application control flow.
  • Simple example: A newsletter becomes ignore and terminates rather than receiving generated prose.
  • Realistic example: Alice’s architecture-review request becomes respond, but today’s placeholder performs no scheduling or delivery.
  • Failure mode: A single prompt says “handle this appropriately,” and a retry sends text or skips an escalation with no inspectable route.
  • Decision rule: Use a workflow whenever an output can change external state, require escalation, or need audit. Use a single completion only for low-risk, stateless text transformation.
def process_email(email):
    decision = decide(email)
    return act(decision)

This function is intentionally incomplete. Its value is the stable boundary: later tools, memory, databases, workers, and evaluations refine these boxes instead of replacing them.

State is request-scoped working memory

Nodes cannot coordinate safely if each reconstructs the request from prose, because facts and tool results can be lost or silently overwritten. Graph state is the typed, request-scoped object that carries 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]
  • General rule: Put data needed by later nodes in explicit typed state; do not treat it as durable memory unless a persistence boundary says so.
  • Simple example: Triage adds classification="ignore"; the router reads exactly that field.
  • Realistic example: Alice’s normalized email, route, model messages, and later tool results share one run ID.
  • Failure mode: A node replaces accumulated messages instead of appending them, or a new invocation accidentally inherits a previous user’s context.
  • Decision rule: Keep request facts and execution progress in graph state; store only facts needed by future requests in long-term memory. Add checkpoints when a run must resume after interruption.

The interface is explicit: ingress produces EmailInput; a node accepts EmailState and returns a partial state update; the graph merges that update. A checkpoint may persist one thread’s progress, but it still answers “what happened in this execution?”, not “what should every future execution know?”

Triage produces a machine-readable route

Free-form reasoning is a poor router because code would have to reinterpret prose and could accept an invented fourth path. Structured output constrains model judgement to a schema the application 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}
  • General rule: Use the narrowest schema that downstream code can act on without another interpretation step.
  • Simple example: {"classification": "notify"} is valid; {"classification": "probably urgent"} is not.
  • Realistic example: Alice’s request yields respond; a suspicious security message yields notify; a bulk newsletter yields ignore.
  • Failure mode: The model emits persuasive reasoning with a misspelled or undeclared route, and the application guesses what it meant.
  • Decision rule: If output selects control flow, money, permissions, or side effects, validate structured output before transition. Preserve reasoning as audit context, never as the routing contract.

Routing belongs to the graph

Classification still causes harm if the model can choose arbitrary destinations or bypass approval. The router maps a validated value onto a fixed topology owned by application code.

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
  • General rule: The model recommends among declared outcomes; the graph determines legal transitions.
  • Simple example: ignore maps to END, a terminal marker meaning no later node runs.
  • Realistic example: Alice enters the response placeholder, while sensitive mail enters a notification node that can require human attention.
  • Failure mode: Triage and response are collapsed, so the same model invocation can both classify and send before policy observes the route.
  • Decision rule: Separate “should work happen?” from “what work should happen?” when paths have different costs, authorities, or review requirements.

Assemble and test the first LangGraph

A diagram is not an executable guarantee; without a compiled topology and path tests, a refactor can connect the right labels to the wrong nodes. StateGraph turns the declared states and transitions into a testable control plane.

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()

Test routes and effects, not literary quality: a newsletter reaches the terminal state, a sensitive fixture reaches notify_user, and Alice’s scheduling request reaches response_agent. For every fixture, assert the immutable trace fields and zero email/calendar mutations. False ignore has a higher consequence for critical mail than an unnecessary notification, so evaluation thresholds should reflect that asymmetry.

Trace the recurring request and choose the next step

Even a correct architecture remains abstract until one request is followed across every interface, and hidden side effects are easiest to miss between boxes. Trace Alice’s message now, then use the resulting evidence as the baseline for the next slice.

Alice sends: “Can we arrange an architecture review next week?” The provider adapter authenticates and normalizes the event. Triage receives the email plus current rules, returns respond, and the graph selects response_agent. The trace records expected and observed route, tenant, environment, time, and run ID. Nothing is sent; no calendar entry exists.

That restraint is the action for today: implement the three fixtures, inspect their terminal traces, and block the release if any invalid route is accepted or any external mutation occurs. Only after those assertions pass should the response placeholder gain tool authority.

Key takeaways

An email workflow becomes unsafe when judgement, legal transitions, and effects blur together, because failures cannot be localized. Keep the first mental model small and the interfaces explicit.

  • An agentic email assistant is a controlled workflow, not an LLM call with a long prompt.
  • Graph state is temporary working memory for one execution; checkpoints and long-term memory solve different persistence problems.
  • Structured output converts model judgement into a validated routing contract.
  • Triage decides whether work should happen; later response logic decides which work to perform.
  • Application code owns legal transitions, and route traces make both positive and negative behavior reviewable.

Checklist

Reading without execution can leave the core safety claim untested. Complete these checks against one happy route and one contained route before adding tools.

  • [ ] I can explain why email → decide → act is a better starting model than email → LLM → text.
  • [ ] I can distinguish graph state, a checkpoint, and long-term memory.
  • [ ] I can name the three triage routes and the terminal behavior of each.
  • [ ] I reject an undeclared classification before transition.
  • [ ] I can produce a run trace proving the selected path and zero external side effects.