Capstone: A Production-Ready Authenticated Email Assistant
This is the last article in the series, and it doesn't introduce a single new concept. Everything you need is already in your hands from the previous fourteen articles. What's left is assembly: building one complete, realistic version of Aria that authenticates Julie before doing anything, adapts her behavior based on that authentication state, and still asks for approval before sending anything irreversible.
If you've followed along this far, you already understand every individual piece below. This article is about how they fit together.
🔴 Skill level: Advanced.
Quick Reference
When to use this: As a reference architecture for a real, production-style agent — not a new technique, but a demonstration of combining prior techniques correctly.
What's being assembled:
- Context (Article 7) — holds the credentials Aria checks against
- Custom state (Article 8) — tracks whether the current session is authenticated
- A tool that writes to state (Article 8) —
authenticate, usingCommand - Dynamic tools (Article 14) — only
authenticateis available before login; only real tools are available after - Dynamic prompts (Article 14) — Aria's instructions change based on authentication state
- Human-in-the-loop (Article 13) —
send_emailalways requires approval, regardless of authentication state
Gotchas:
- ⚠️ Authentication state lives in agent state, not context — it has to be something a tool can change mid-conversation (you don't know if someone's authenticated until they try), which is exactly the context-vs-state distinction from Articles 7 and 8.
- ⚠️ Order matters in the middleware list — and in this build, dynamic tool restriction needs to run before the model decides what to call.
See also: every prior article in this series.
What You Need to Know First
- All fourteen previous articles in this series — this capstone doesn't re-teach any individual concept, only combines them
What We'll Cover in This Article
- Designing the full authentication flow: credentials, state, and a tool that checks them
- Gating tool access based on authentication state, not just user role
- Adapting the system prompt based on authentication state
- Keeping
send_emailbehind human approval, regardless of everything else
Designing the Shape of the Final Agent
Let's lay out exactly what we're building before writing it, since combining this many pieces benefits from a clear plan first.
Diagram: Every message checks authentication state first. Unauthenticated sessions can only call authenticate. Once authenticated, real tools become available — but send_email still always requires a human approval step, regardless of authentication.
Notice authentication and human-in-the-loop are solving two genuinely different problems, even though both involve a kind of "gate." Authentication answers "is this a legitimate session?" — it's about identity. Human-in-the-loop answers "should this specific action actually happen?" — it's about consequences. A fully authenticated session still shouldn't be allowed to send an email with zero human review.
Building the Pieces
Credentials as Context
The credentials Aria checks against are fixed, known-in-advance facts — exactly what context (Article 7) is for:
# Purpose: Define the credentials Aria will check incoming login attempts against
# Context: Fixed, set once by your code — this is what context is for
# Input: N/A
# Output: A reusable context class
from dotenv import load_dotenv
load_dotenv()
from dataclasses import dataclass
@dataclass
class EmailContext:
email_address: str = "julie@example.com"
password: str = "password123" # for demonstration only — see the security note below
⚠️ This is a teaching simplification. Hardcoding a password, even as a default, is never appropriate in a real application. A production system would check credentials against a real authentication service, never compare plaintext passwords directly in code. We're keeping this simple specifically to focus on the pattern of gating behavior on authentication state, not on building real authentication infrastructure.
Authentication State
Whether the current session is authenticated isn't fixed in advance — it depends on what happens during the conversation. That's state (Article 8), not context:
# Purpose: Track whether the current session has successfully authenticated
# Context: This changes DURING a conversation, which is why it's state, not context
# Input: N/A
# Output: A state class with one extra field beyond the defaults
from langchain.agents import AgentState
class AuthenticatedState(AgentState):
authenticated: bool
The authenticate Tool
This tool checks provided credentials against context, and writes the result to state — combining the Command-based state-writing pattern from Article 8 with the context-reading pattern from Article 7:
# Purpose: Check provided credentials against the real ones in context,
# and record the result in state
# Context: Combines reading context (Article 7) with writing state (Article 8)
# Input: An email and password, presumably typed by the user
# Output: A Command updating "authenticated" in state, plus a confirmation message
from langchain.tools import tool, ToolRuntime
from langgraph.types import Command
from langchain.messages import ToolMessage
@tool
def authenticate(email: str, password: str, runtime: ToolRuntime) -> Command:
"""Authenticate the user with the given email and password."""
correct = (
email == runtime.context.email_address
and password == runtime.context.password
)
return Command(update={
"authenticated": correct,
"messages": [
ToolMessage(
"Successfully authenticated" if correct else "Authentication failed",
tool_call_id=runtime.tool_call_id,
)
],
})
The Real Tools
These are Aria's familiar, established abilities — only available after authentication succeeds:
# Purpose: Aria's real abilities — only meaningful once a session is authenticated
# Context: Same shape as the tools from Articles 2 and 13
# Input: Varies by tool
# Output: Inbox contents, or a sent-email confirmation
from langchain.tools import tool
@tool
def check_inbox() -> str:
"""Check the inbox for recent emails."""
return (
"Hi Julie, I'm going to be in town next week and was wondering "
"if we could grab a coffee? - best, Jane (jane@example.com)"
)
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send a reply email."""
return f"Email sent to {to} with subject '{subject}'"
Dynamic Tool Access Based on Authentication
This is @wrap_model_call (Article 14) again, but gating on authenticated from state, rather than a fixed role from context:
# Purpose: Only expose authenticate before login; only expose real tools after
# Context: Same @wrap_model_call pattern from Article 14, gating on STATE this time
# Input: The current request
# Output: A request with tools restricted based on authentication state
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from typing import Callable
@wrap_model_call
async def dynamic_tool_access(
request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
"""Restrict tool access based on whether this session is authenticated."""
authenticated = request.state.get("authenticated")
if authenticated:
tools = [check_inbox, send_email]
else:
tools = [authenticate]
request = request.override(tools=tools)
return await handler(request)
Dynamic Prompt Based on Authentication
Aria's instructions should reflect what she's actually capable of doing right now:
# Purpose: Give Aria different instructions depending on authentication state
# Context: Same @dynamic_prompt pattern from Article 14
# Input: The current request
# Output: A system prompt matching what Aria can currently do
from langchain.agents.middleware import dynamic_prompt
authenticated_prompt = "You are Aria, a helpful assistant that can check the inbox and send emails."
unauthenticated_prompt = "You are Aria, a helpful assistant that can authenticate users before helping further."
@dynamic_prompt
def aria_prompt(request: ModelRequest) -> str:
"""Generate Aria's system prompt based on authentication state."""
authenticated = request.state.get("authenticated")
return authenticated_prompt if authenticated else unauthenticated_prompt
Human-in-the-Loop, Regardless of Authentication
send_email requires approval — full stop, whether or not the session is authenticated:
# Purpose: Require human approval before send_email runs, regardless of
# anything else about the session
# Context: Same HumanInTheLoopMiddleware pattern from Article 13
# Input: N/A
# Output: Middleware configuration
from langchain.agents.middleware import HumanInTheLoopMiddleware
approval_required = HumanInTheLoopMiddleware(
interrupt_on={
"authenticate": False,
"check_inbox": False,
"send_email": True,
},
)
Assembling the Complete Agent
Every piece, combined:
# Purpose: The complete, production-style authenticated email assistant
# Context: Combines context, state, dynamic tools, dynamic prompts, and HITL
# Input: N/A — this is the final agent definition
# Output: A fully assembled agent, ready to invoke
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
"gpt-5-nano",
tools=[authenticate, check_inbox, send_email],
state_schema=AuthenticatedState,
context_schema=EmailContext,
checkpointer=InMemorySaver(), # required for both state and HITL to persist
middleware=[
dynamic_tool_access,
aria_prompt,
approval_required,
],
)
Let's walk through what actually happens, end to end:
# Purpose: Walk through the full flow — unauthenticated, then authenticated,
# then a gated send
# Context: Demonstrates every piece working together
# Input: A sequence of messages and resume decisions
# Output: Aria refusing to help until authenticated, then helping, then pausing for approval
from langchain.messages import HumanMessage
from langgraph.types import Command
config = {"configurable": {"thread_id": "1"}}
context = EmailContext()
# Step 1: Try to use Aria before authenticating
response = agent.invoke(
{"messages": [HumanMessage(content="What's in my inbox?")]},
config=config,
context=context,
)
print(response["messages"][-1].content) # Aria can't help yet — only authenticate is available
# Step 2: Authenticate
response = agent.invoke(
{"messages": [HumanMessage(
content="My email is julie@example.com and my password is password123"
)]},
config=config,
context=context,
)
print(response["messages"][-1].content) # Authentication should succeed
# Step 3: Now ask again — real tools should be available
response = agent.invoke(
{"messages": [HumanMessage(content="Check my inbox and draft a reply to Jane confirming coffee.")]},
config=config,
context=context,
)
print(response.get("__interrupt__")) # send_email should now be pending approval
# Step 4: Approve the pending send
response = agent.invoke(
Command(resume={"decisions": [{"type": "approve"}]}),
config=config,
context=context,
)
print(response["messages"][-1].content) # The email is actually sent now
Every piece from this series is present in that one flow: context holding credentials, state tracking the session, dynamic tools and prompts adapting to authentication, a checkpointer making all of it persist across separate calls, and a human approval gate standing between Aria and an irreversible action — no matter how trusted the session already is.
Common Misconceptions
❌ Misconception: Authentication and human-in-the-loop solve the same problem
Reality: Authentication establishes who is in the session. Human-in-the-loop decides whether a specific action should actually happen. A fully authenticated, fully trusted session still shouldn't bypass approval for something irreversible — they're independent layers, not redundant ones.
Why this matters: Don't assume that once a session is authenticated, every gate can be dropped — sending an email is risky regardless of who's logged in.
❌ Misconception: This is what "real" authentication looks like
Reality: This article demonstrates the pattern of gating agent behavior on an authentication state — it deliberately does not implement real, secure credential checking (hashed passwords, real auth providers, session tokens). A production system needs genuine security infrastructure behind this pattern, not a hardcoded password comparison.
Troubleshooting Common Issues
Problem: The agent never seems to "remember" authentication across messages
Symptoms: Even after step 2 succeeds, step 3 still behaves like an unauthenticated session.
Common Causes:
- A different
thread_idor missingconfigwas used between calls (most common — same root cause as memory issues all the way back in Article 4) - No checkpointer was configured
Solution: Confirm the exact same config is reused across every call in the flow, and that checkpointer=InMemorySaver() is actually set.
Problem: send_email runs without ever pausing for approval
Symptoms: Step 3 completes immediately with a sent-email confirmation, no interrupt.
Common Causes:
HumanInTheLoopMiddlewareisn't included inmiddleware=[...], or itsinterrupt_ondoesn't actually marksend_emailasTrue
Solution: Double-check the middleware list includes approval_required, and that "send_email": True is set correctly in interrupt_on.
Check Your Understanding
Quick Quiz
-
Why does authentication status live in state rather than context?
Show Answer
Because it changes during the conversation, based on whether a login attempt succeeds — context is fixed in advance by your code, while state is exactly the mechanism for information that's determined as the conversation unfolds.
-
If a session is authenticated, does it skip the human-in-the-loop approval for sending an email?
Show Answer
No — authentication and human-in-the-loop are independent layers.
send_emailrequires approval regardless of authentication state, because they answer different questions (who is this? vs. should this specific action happen?). -
What's the one thing this capstone deliberately does NOT provide?
Show Answer
Real, production-grade authentication security — the credential check here is a hardcoded comparison for teaching purposes, not something to use as-is in a real application handling real credentials.
Hands-On Exercise
Challenge: Add a third state, distinguishing a locked out session (e.g., after 3 failed authentication attempts) from simply unauthenticated, and adjust the dynamic prompt to reflect it.
Show Solution
class AuthenticatedState(AgentState):
authenticated: bool
failed_attempts: int
@tool
def authenticate(email: str, password: str, runtime: ToolRuntime) -> Command:
"""Authenticate the user, tracking failed attempts."""
correct = (
email == runtime.context.email_address
and password == runtime.context.password
)
if correct:
return Command(update={
"authenticated": True,
"messages": [ToolMessage("Successfully authenticated", tool_call_id=runtime.tool_call_id)],
})
try:
current_failures = runtime.state["failed_attempts"]
except KeyError:
current_failures = 0
new_failures = current_failures + 1
message = "Authentication failed" if new_failures < 3 else "Account locked — too many failed attempts"
return Command(update={
"authenticated": False,
"failed_attempts": new_failures,
"messages": [ToolMessage(message, tool_call_id=runtime.tool_call_id)],
})
Explanation: This follows the same try/except state-reading safety pattern from Article 8, and the same Command(update={...}) writing pattern — extending the capstone's authentication flow doesn't require any new concepts, just applying what's already been covered.
Summary: Key Takeaways
- This capstone introduced no new concepts — it combined context (7), state (8), human-in-the-loop (13), and dynamic middleware (14) into one realistic agent
- Context holds fixed credentials; state tracks whether the current session has actually authenticated — a direct application of the context-vs-state distinction from Articles 7 and 8
- Authentication and human-in-the-loop are independent layers, solving different problems — being logged in doesn't bypass approval for risky actions
- A checkpointer is required for the whole flow to work, since both state and interrupts depend on it persisting across calls
- This pattern is a real foundation — production use would still need genuine authentication infrastructure behind it, not the simplified credential check shown here
- Aria is now a complete, realistic assistant — everything from the first article's system prompt to this article's full authentication flow
What's Next?
You've completed the entire Building Agents with LangChain series — from a single prompted agent with no abilities at all, to a fully authenticated, context-aware, dynamically adaptive, human-supervised production agent.
From here, two natural directions: dig deeper into LangGraph's lower-level graph API for even finer control over agent behavior than create_agent exposes, or explore deploying an agent like this one as a real, always-on service rather than a script you run locally.
References
- LangChain Academy: Introduction to LangChain (Python) — this section is inspired by and adapted from this course
- LangChain Docs: Agents — official guide to
create_agentand its full configuration surface - LangChain Docs: Middleware — official guide covering every middleware type used in this capstone
- LangGraph Docs: Persistence — official guide to checkpointers
langchainon PyPI — latest version and release history