🤖 Automate your inbox. 🧑💻 Stay in control. 🧠 Let your assistant learn from you.
📖 Table of Contents (click to expand)
- 🎯 High‑Level Purpose
- 🛠️ Key Libraries & Concepts
- ⚙️ Configuration & LLMs
- 🧠 Memory Helpers
- 📤 Triage Phase
- 🔧 Action Phase
- 🗺️ Building the Graphs
- 🔄 End-to-End Data Flow
- ⏳ Example Runtime
- 🚀 How to Run
- 🧩 Customization Points
⚠️ Pitfalls & Best Practices- 📚 Glossary
- 🔁 What Happens on Each Outcome?
- 🧠 Quick Mental Model
You’re building an email assistant that:
- 📤 Triages incoming emails (respond / notify / ignore).
- ✍️ Writes replies or schedules meetings using tools.
- 🧑💻 Uses Human‑in‑the‑Loop (HITL) checkpoints so you can accept/edit/ignore actions from an Agent Inbox UI.
- 🧠 Learns preferences over time via a store (LangGraph
BaseStore) updated by an LLM that outputs a structuredUserPreferencesmodel.
-
LangGraph: Builds stateful graphs of steps (nodes) with edges and conditions.
StateGraph,START,END: define nodes and control flow.interrupt(...): pauses execution and surfaces a request to a human UI (Agent Inbox) and resumes when you answer.Command: return type to instruct the graph where to go next and how to update state.
-
LangChain Google GenAI:
ChatGoogleGenerativeAILLM wrapper around Gemini models.with_structured_output(...): forces the LLM to return data that conforms to a schema (likeRouterSchema,UserPreferences).bind_tools(...): makes the LLM pick between available tools and emit tool calls.
-
Store (LangGraph
BaseStore): a key‑value store for long‑term memory (preferences). You read and update it inside nodes. -
Schemas (your
agent.schemas):State: the graph’s state (fields likeemail_input,messages,classification_decision, etc.).RouterSchema: output of the triage router (e.g.,.classification ∈ {respond, ignore, notify}).StateInput: the expected input when you start the graph.UserPreferences: the structured object used to persist preferences.
-
Tools (your
agent.tools): actions the agent can take (e.g.,write_email,schedule_meeting,check_calendar_availability,Question,Done).
-
Environment:
load_dotenv(".env")soGOOGLE_API_KEYis available for the Gemini models. -
Tool registry:
tools = get_tools(["write_email", "schedule_meeting", "check_calendar_availability", "Question", "Done"]) tools_by_name = get_tools_by_name(tools)
You keep a list of tool definitions and an index by name for quick lookups.
-
Router LLM (classification only):
llm_router = ChatGoogleGenerativeAI(...).with_structured_output(RouterSchema)
Ensures the model returns a typed object (e.g.,
.classification). -
Agent LLM (action‑oriented):
llm_with_tools = ChatGoogleGenerativeAI(...).bind_tools(tools, tool_choice="any")
Lets the model choose which tool to call given the system prompt and conversation so far.
- Reads a key (
"user_preferences") under a namespace tuple like("email_assistant", "triage_preferences"). - If present, returns
user_preferences.value. - If absent, it initializes the store with
default_contentand returns that. - Why: Guarantees the rest of the code always has a preference profile to use.
- Loads the current profile from the store.
- Calls an LLM with structured output
UserPreferencesusing a system promptMEMORY_UPDATE_INSTRUCTIONS(filled with the current profile + namespace), plus messages (signals from the latest human/agent interaction). - Writes back
result.user_preferencesto the store. - Why: Lets the assistant learn from edits/accept/ignore decisions by the human and update long‑term behavior.
Tip: Ensure your store’s values are either plain JSON‑serializable dicts or Pydantic‑model‑compatible.
store.get(...).valueshould be whatever the LLM expects ascurrent_profilein the prompt.
Goal: Decide respond / notify / ignore for the input email.
Steps
-
Parse the email:
author, to, subject, email_thread = parse_email(state["email_input"]). -
Build user_prompt using
triage_user_promptand the parsed fields. -
Build email_markdown via
format_email_markdown(...)(nice rendering for UI/Agent Inbox). -
Load triage preferences using
get_memory(..., ("email_assistant","triage_preferences"), default_triage_instructions). -
Compose a system prompt using
triage_system_prompt.format(background=..., triage_instructions=...). -
Call the router LLM:
result = llm_router.invoke([...])→ returnsRouterSchema(typed). -
Branch on
result.classification:- respond → set
goto = "response_agent", add a message instructing to respond (with the markdown email included). - ignore → set
goto = END. - notify → set
goto = "triage_interrupt_handler"(so a human can confirm what to do).
- respond → set
-
Return a
Command(goto=..., update=...)that both moves the graph and updatesstate.
Goal: When triage says notify, pause for human input.
Steps
-
Re‑parse the email and render
email_markdown. -
Create a HITL request (
request = {...}) describing the situation and allowed actions. -
response = interrupt([request])[0]→ stops execution and yields control to Agent Inbox until the human responds. -
If human chooses
response(they want to reply):- Append a user message with their feedback.
- Update memory (
triage_preferences) to reflect that the user does want to respond to this type. goto = "response_agent".
-
If human chooses
ignore:- Update triage memory to reflect that similar emails should be less likely to be classified as respond.
goto = END.
-
Return
Command(goto=..., update={"messages": messages}).
Goal: Let the model decide which tool(s) to call to handle the email.
Steps
-
Load calendar and response preferences from memory.
-
Build a powerful system prompt (
agent_system_prompt_hitl_memory) that injects:- Tool usage guidelines (
HITL_MEMORY_TOOLS_PROMPT), - Org background (
default_background), - User’s response & calendar preferences.
- Tool usage guidelines (
-
Call
llm_with_tools.invoke([...]+state["messages"])so the LLM can select tools and emit tool calls. -
Return a dict
{ "messages": [ <AI message with tool_calls> ] }that merges into graph state.
The AI message includes
tool_callslike[{"name": "write_email", "args": {...}, "id": "..."}].
Goal: Intercept tool calls that require human oversight and route them through Agent Inbox.
Flow
-
Initialize
result = [], defaultgoto = "llm_call"(so we can keep iterating if needed). -
Iterate all
tool_callsin the last AI message. -
Define the HITL whitelist:
hitl_tools = ["write_email", "schedule_meeting", "Question"].- If a tool is not in the whitelist (e.g.,
check_calendar_availability), execute immediately and append a{role:"tool", content: observation, tool_call_id: ...}message.
- If a tool is not in the whitelist (e.g.,
-
For HITL tools, prepare a rich description containing the original email and a formatted view of the tool call (
format_for_display). -
Configure allowed actions per tool (which buttons appear in Agent Inbox):
write_email/schedule_meeting: allow ignore, respond, edit, accept.Question: allow ignore, respond (no edit/accept since it’s a question).
-
Create a HITL request and
interrupt([request])[0]. -
Handle human outcomes:
- accept → execute tool with original args; append the tool response.
- edit → replace the tool call in the AI message with edited args, execute the tool, append the tool response, update memory (
response_preferencesorcal_preferences) with initial vs. edited. - ignore → append a tool message instructing to ignore and set
goto = END; update triage memory to reduce similar future false positives. - response → human gives free‑text feedback; append a tool message containing that feedback (no execution yet); update preferences accordingly.
-
After processing all tool calls, return
Command(goto=goto, update={"messages": result}).
-
Looks at the last message. If it has any
tool_calls:- If any tool is
Done→ returnEND. - Else → return
"interrupt_handler"(so we run the HITL logic).
- If any tool is
-
Note: If the LLM returns no tool calls, nothing is returned here as written. Prefer to handle that explicitly (see Pitfalls below).
agent_builder = StateGraph(State)
agent_builder.add_node("llm_call", llm_call)
agent_builder.add_node("interrupt_handler", interrupt_handler)
agent_builder.add_edge(START, "llm_call")
agent_builder.add_conditional_edges(
"llm_call",
should_continue,
{"interrupt_handler": "interrupt_handler", END: END},
)
response_agent = agent_builder.compile()- This subgraph loops: LLM proposes tools → maybe HITL → back to LLM until a
Donetool or an ignore/end condition.
overall_workflow = (
StateGraph(State, input_schema=StateInput)
.add_node(triage_router)
.add_node(triage_interrupt_handler)
.add_node("response_agent", response_agent)
.add_edge(START, "triage_router")
)
email_assistant_hitl_memory = overall_workflow.compile()- Flow: START → triage_router → either END (ignore), triage_interrupt_handler → response_agent, or directly response_agent (if
respond).
flowchart TD
A[START] --> B[triage_router\nRouter LLM classifies]
B -.->|ignore| Z[END]
B -.->|respond| D[response_agent]
B -.->|notify| C[triage_interrupt_handler\nHITL decision]
C -.->|human: ignore| Z
C -.->|human: respond| D
subgraph Response Agent
D --> E[llm_call\nLLM emits tool_calls]
E -.->|tool_calls & not Done| F[interrupt_handler\nHITL for tools]
F -.->|accept/edit| E
F -.->|ignore| Z
E -.->|Done tool| Z
end
sequenceDiagram
autonumber
participant User
participant Graph as LangGraph
participant Router as Router LLM
participant Agent as Agent LLM
participant Inbox as Agent Inbox (HITL)
participant Store as Memory Store
participant Tool as External Tools
%% ---- Initial Invocation ----
User->>Graph: invoke({ email_input, messages: [] }, config{ store })
Graph->>Router: system+user prompts (triage)
Router-->>Graph: classification = schedule_call
%% ---- Scheduling Preference Flow ----
Graph->>Store: get scheduling preferences
Graph->>Agent: system + tools + email_input
Agent-->>Graph: tool_calls = [schedule_call(args)]
Graph->>Inbox: interrupt(request: schedule_call)
Inbox-->>Graph: edit(schedule_args')
Graph->>Store: update_memory(scheduling_preferences)
Graph->>Tool: schedule_call(schedule_args')
Tool-->>Graph: scheduling result (proposed time)
Graph-->>User: confirm scheduling draft
%% ---- Accept Scheduling ----
User->>Graph: accept scheduling
Graph->>Store: persist final scheduling preferences
Graph->>Router: continue flow → write_email
Router-->>Graph: classification = respond_email
%% ---- Email Drafting Flow ----
Graph->>Agent: system + tools + context
Agent-->>Graph: tool_calls = [write_email(args)]
Graph->>Inbox: interrupt(request: write_email)
Inbox-->>Graph: edit(email_args')
Graph->>Store: update_memory(response_preferences)
Graph->>Tool: write_email(email_args')
Tool-->>Graph: draft email result
Graph-->>User: preview email draft
%% ---- Accept Edited Email ----
User->>Graph: accept email
Graph->>Store: persist response preferences
Graph->>Router: continue flow → send_email
Router-->>Graph: classification = send_email
Graph->>Tool: send_email(final_email)
Tool-->>Graph: send result (success)
%% ---- Final Done ----
Graph-->>User: ✅ Done (scheduled + email sent)
from langgraph.store.memory import InMemoryStore
store = InMemoryStore()
inputs = {
"email_input": "From: alice@example.com\nTo: you@example.com\nSubject: Meeting\n\nCan we meet tomorrow?",
"messages": []
}
config = {
# Needed so nodes can call `store.get/put`
"store": store,
# Optional: set a thread/checkpoint id if you want resumability
"configurable": {"thread_id": "thread-123"}
}
result = email_assistant_hitl_memory.invoke(inputs, config=config)
print(result)If you’re running inside LangGraph Studio or any UI that supports Agent Inbox, the
interrupt(...)calls will open review tasks. In a pure script, you need an event loop that services those interrupts (Studio handles this for you).
- Add/Remove tools: change the list in
get_tools([...]), and update the HITL allow‑lists ininterrupt_handler. - Prompts: tune
triage_system_prompt,triage_user_prompt,agent_system_prompt_hitl_memory, and the default preference snippets. - Memory namespaces: you’re using
("email_assistant", "triage_preferences"|"response_preferences"|"cal_preferences"). Add more namespaces to learn different behaviors. - HITL policies: tweak per‑tool
config(allow_edit/accept/respond/ignore) based on risk. - End condition: the
Donetool is your explicit stop. You can also end after certain tool outputs or add safety checks.
-
should_continuewhen there are no tool calls: currently returns nothing (implicitNone). Add a default:def should_continue(state, store): last = state["messages"][-1] if getattr(last, "tool_calls", None): for tc in last.tool_calls: if tc["name"] == "Done": return END return "interrupt_handler" return END # or a node that builds a plain text reply
-
Store value shape: Make sure
store.get(...).valuematches what your prompts expect. If your store returns the raw object, you may needjson.dumps(...)or.model_dump()when formatting prompts. -
Schema consistency: Verify your
RouterSchemafields line up with usage (.classification). -
Tool execution side effects: Tools like
schedule_meetingshould be idempotent or guarded—avoid accidental double‑booking when the user edits/accepts. -
Error handling: Wrap tool invocations in try/except to return helpful tool error messages back to the graph.
-
LLM determinism: You set
temperature=0.0—good for predictability. -
Security: Sanitize any content you inject into prompts (avoid prompt‑injection via email content).
- HITL: Human‑in‑the‑Loop—pausing the agent to ask a person before proceeding.
- Agent Inbox: UI where interrupts show up; you can accept/edit/ignore.
- Namespace: A tuple key segment for the store, e.g.,
("email_assistant", "triage_preferences"). - Structured Output: Forcing LLM to return data conforming to a Pydantic schema.
- Triage = ignore → Flow ends; memory can be updated later based on future interactions.
- Triage = notify → HITL asks you; your response updates triage preferences.
- Triage = respond → Agent proposes tool calls; HITL validates risky ones.
- HITL accept → Execute tool.
- HITL edit → Execute tool with edits, then learn from the delta.
- HITL ignore → End flow and learn to avoid similar cases.
- HITL response → Capture feedback; optionally loop to get a better draft before executing.
- Classify the email.
- If needed, ask a human about the classification.
- Act (write/schedule) with tools chosen by the model.
- Ask a human before risky actions.
- Learn from the human’s decisions.
- Repeat until
Doneor the human says stop.
✨ If you want, we can also annotate your exact code inline (adding comments to each line) or run through a concrete email example end‑to‑end with sample Agent Inbox interactions. ✨