learn-langchain-with-phoebe / Builder session 7 of 10
Learn LangChain with Phoebe · Builder track · Session 7 of 10

Human-in-the-loop and time travel

Last week's checkpointer was sold as memory. Tonight it cashes in as CONTROL: pause a run before anything irreversible, show a human exactly what is about to happen, let them approve, edit, or reject - then rewind any past run and fork an alternative future from it. DataDesk learns to ask permission before it writes a file, and you learn to debug agents like a video editor.

🟠 Builder track Practitioners: DA · DE · DS · engineers Python 3.10+ · langgraph>=1.2 · your b6 datadesk_v3.py 45 min
0-3 · Welcome 3-18 · Gates + rewind 18-42 · Build-along: DataDesk asks permission 42-45 · Q&A
Part 0

Where governance becomes code

The leader track spends a whole session (a4) on WHY agents need human gates - irreversible actions, external sends, spend. Tonight is the HOW, and it is smaller than the leaders imagine: because b6's checkpointer already saves state at every step, "pause here and ask a human" is just a checkpoint nobody has resumed yet. No queues, no callback servers - one compile argument and three verbs: approve, edit, reject. Time travel falls out of the same machinery for free.

Live - presented in session Self-study - read after class ★ Try it now prompt Official docs + Academy covered
★ What you walk out with today DataDesk v4 with a real approval gate on its first WRITE action (saving report files), the full approve / edit / reject triangle performed live, a rewind of a past conversation replayed step by step, and a forked what-if branch - the debugging move that makes agent failures reproducible.
Part 1 · covers LangGraph HITL + breakpoint docs

The approval gate 9 min live

Not every step deserves a gate. The rule: gate where the cost of a mistake exceeds the cost of a delay. Then the mechanics - pause BEFORE the risky node, surface the pending action, resume on a human verdict.

Agent lane draft report ⏸ gate checkpoint, waiting save_report done Human lane review pending write: path + text approve · edit the state first · reject state surfaced resume on verdict The gate is just a checkpoint nobody resumed yet - b6 built this, tonight names it.
🔍 Click to zoom - the interrupt flow: pause, review, resume
LiveWhy gates - the leader's rule arrives in code4 min

Until tonight DataDesk only READ things: CSVs, questions. Reads are cheap to get wrong - re-run and shrug. The moment an agent WRITES - files, tickets, emails, database rows, money - a wrong action has cleanup cost, and some have no cleanup at all. The rule your architecture should encode:

  • Gate where mistake cost > delay cost. Saving a file over last month's report, sending anything external, spending anything - gated. Reformatting a draft the human will read anyway - not gated.
  • The gate must show the ACTUAL action, not a summary of intent: the exact path, the exact text, the exact recipient. Approving a vibe is not approval.
  • Rejection must be cheap. If saying no wastes the whole run, reviewers stop saying no. With checkpoints, a reject parks the thread - nothing upstream is lost.
★ Try it now (any chat AI)Here are 5 actions my planned data assistant could take: [list yours, e.g. query a table, save a report, post to Slack, open a Jira ticket, email a stakeholder]. Classify each as gate / no gate using the rule "gate where mistake cost exceeds delay cost", and say what the reviewer must SEE before approving. One line each.
Real world

The July 2025 Replit incident, revisited from the builder's chair. An agent with write access deleted a production database during a code freeze. Every postmortem take agreed on the same missing artifact: a pause between "the agent wants to run this" and "this ran". That pause is tonight's compile argument. The leader track teaches your executives to demand it; you are about to be the person who can say "it is already there".

LiveInterrupts and breakpoints - pause, surface, resume5 min

The static form: name the risky node at compile time. The run stops BEFORE it, mid-flight state saved:

★ The gate, the peek, and the three verbsgraph = g.compile(checkpointer=saver, interrupt_before=["save_report"]) # the gate graph.invoke({"messages": [...]}, cfg) # runs draft, then PAUSES s = graph.get_state(cfg) print(s.next) # ('save_report',) - what is about to run print(s.values["path"]) # the pending write, before it happens graph.invoke(None, cfg) # APPROVE - resume as-is graph.update_state(cfg, {"path": "reports/q3.md"}) # EDIT - then resume # REJECT - simply never resume; the thread parks, nothing was written
  • invoke(None, config) is the resume idiom: "no new input, continue from the checkpoint".
  • update_state writes a correction into the checkpoint before resuming - the human is briefly a node in the graph.
  • The pause has no timeout. Approve in three seconds or three days; the checkpoint waits in the database either way. That is why b6 was the prerequisite - no checkpointer, no gate.
Self-studyTwo altitudes: middleware HITL vs graph interrupts, and dynamic gates4 min read
  • Same concept, two altitudes. At the create_agent level, b4's HumanInTheLoopMiddleware gates tool calls by name with approve/edit/reject policies - configuration, not construction. At the graph level, tonight's interrupts gate ANY node - including deterministic ones middleware never sees. Because create_agent runs ON LangGraph, both are the same checkpoint mechanics underneath; pick the altitude you are already building at.
  • Dynamic gates. A node can call interrupt(payload) from INSIDE its own logic - pause only when THIS run needs a human (amount over threshold, confidence under threshold), surfacing exactly the payload the reviewer needs. Resuming feeds the human's answer back into the node via Command(resume=...). Static gates express policy ("all writes are reviewed"); dynamic gates express judgment ("this write looks risky").
  • Where the human actually clicks. In production, the paused thread surfaces in your app: a Slack message, a review queue, an inbox row. The graph does not care - it sees only "resumed with a verdict". Your thread_id-mapping homework from b6 is exactly the plumbing this needs.
Part 2 · covers LangGraph time-travel docs

Time travel 5 min live

b6 taught you that history is a list of full snapshots. Tonight's second trick: any snapshot is a valid launch pad. Replay to reproduce, fork to explore.

Main run ckpt 1 ckpt 2 ckpt 3 ckpt 4 forked future "what if I asked differently?" update_state on ckpt 3's config = a new branch, old one intact Replay = rerun the same future. Fork = branch a new one. The original never changes.
🔍 Click to zoom - one past, two futures: replay and fork from checkpoint 3
LiveRewind: replay any past, fork any future5 min

Three moves, all built on get_state_history:

  • Browse: graph.get_state_history(cfg) yields every checkpoint of the thread, newest first - each with its own config handle.
  • Replay: graph.invoke(None, past.config) re-executes from that snapshot - same state in, so you watch the same decisions unfold. Reproducing a flaky agent failure stops being folklore.
  • Fork: graph.update_state(past.config, {...}) returns a NEW config - a branch. Run it and you have two futures from one past, comparable side by side. The original history is untouched.
The rename that helps Stop calling it time travel for a second: it is git for conversations. History = log, replay = checkout + run, fork = branch. Every intuition you have about branches transfers.
Self-studyDebugging with time travel - the incident workflow3 min read

The workflow that changes on-call life, step by step:

  • Reproduce exactly. User reports "the agent gave a nonsense answer at 14:32". Pull the thread, list history, replay from the checkpoint before the bad step. No screenshots, no "cannot reproduce" - the state is the repro.
  • Bisect the run. Replay from successively earlier checkpoints until the answer goes bad - now you know WHICH node's input was already poisoned versus which node did the poisoning.
  • Fork the fix. Edit the poisoned state (or the prompt/tooling) on a fork and re-run the same future. Fix verified against the real failing case before you ship it - and the original run is preserved as evidence for the postmortem.
  • Know the limits. Replay re-executes LIVE calls: a model may answer differently, a tool may hit changed data. Determinism grows with how much of the state you pin - another argument for small, explicit state.
Demo 1 of 2

DataDesk asks permission ★ 14 min · everyone builds

DataDesk's first WRITE action - saving a report file - arrives pre-gated. You will run the full triangle: approve and watch the file appear, reject and watch nothing happen, then edit the path mid-pause and approve the corrected write.

Create a scratch reports/ directory in your DataDesk project. Everything written tonight lands there and nowhere else - gates or not, blast-radius discipline stays.

Add the report flow to your graph: a draft node (model writes the summary text and proposes a path) and a save_report node (writes the file). Fixed edges: START → draft → save_report → END.

Compile with the gate: interrupt_before=["save_report"] plus your b6 checkpointer. Run "Save me a one-paragraph summary report of this week". It drafts... and stops. Print get_state(cfg).next - the pending node, visible.

Approve: inspect s.values["path"] and the text, then graph.invoke(None, cfg). The file appears in reports/. Reject: re-run on a fresh thread and simply do not resume - confirm no file was written. The no-op IS the feature.

Edit then approve: third run, pause, then graph.update_state(cfg, {"path": "reports/week-29-summary.md"}) and resume. The file lands at YOUR path, not the model's. You just acted as a node in the graph.

★ datadesk_v4.py - the gated writefrom pathlib import Path from typing import TypedDict, Annotated from langchain.chat_models import init_chat_model from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.checkpoint.memory import InMemorySaver model = init_chat_model("claude-haiku-4-5-20251001") # or "ollama:llama3.1" class ReportState(TypedDict): messages: Annotated[list, add_messages] path: str text: str def draft(state: ReportState): r = model.invoke(state["messages"]) return {"text": r.content, "path": "reports/summary.md"} def save_report(state: ReportState): Path(state["path"]).write_text(state["text"]) return {"messages": [{"role": "assistant", "content": f"Saved report to {state['path']}"}]} g = StateGraph(ReportState) g.add_node("draft", draft) g.add_node("save_report", save_report) g.add_edge(START, "draft") g.add_edge("draft", "save_report") g.add_edge("save_report", END) graph = g.compile(checkpointer=InMemorySaver(), interrupt_before=["save_report"]) # the gate cfg = {"configurable": {"thread_id": "report-1"}} graph.invoke({"messages": [{"role": "user", "content": "Save me a one-paragraph summary report of this week"}]}, cfg) s = graph.get_state(cfg) print(s.next, "|", s.values["path"]) # paused; the pending write, visible graph.update_state(cfg, {"path": "reports/week-29-summary.md"}) # EDIT graph.invoke(None, cfg) # APPROVE
Wiring it into DataDesk proper In your real datadesk file, add "report" as a fourth route in the b5 classifier and hang draft → save_report off it. The demo file above isolates the gate so everyone sees the pause with zero routing noise - merge after class.
Demo 2 of 2

Rewind Tuesday ★ 8 min · build your own

Take a finished conversation, list its photo stack, relaunch from the middle of it twice - once as replay, once as a fork with a different question - and diff the two futures.

Use a thread with a few turns on it (this morning's gate run, or your b6 "phoebe-monday"). List its history and print one line per checkpoint: id prefix, what runs next, message count.

Pick a mid-conversation checkpoint - call it Tuesday. graph.invoke(None, past.config): the same future re-runs in front of you. Note anything that differs (live model calls may vary - that is a finding, not a bug).

Fork: update_state(past.config, ...) with a different question, capture the returned config, and run it. Two futures now exist from one past.

Compare the final answers of the original branch and the fork side by side. This diff - same history, one changed input - is the cleanest prompt-debugging instrument you own from tonight.

★ Browse, replay, forkhistory = list(graph.get_state_history(cfg)) for s in history: print(s.config["configurable"]["checkpoint_id"][:8], s.next, len(s.values.get("messages", []))) past = history[3] # pick a mid-run checkpoint graph.invoke(None, past.config) # REPLAY - rerun the same future fork_cfg = graph.update_state(past.config, {"messages": [{"role": "user", "content": "Make it an executive bullet list instead"}]}) graph.invoke(None, fork_cfg) # FORK - a new future, old one intact
Real world

The flaky Friday agent, caught. A team's report agent produced a wrong total roughly once a week, never on demand. Once checkpointing landed, the on-call pulled the failing thread, bisected by replay, and found a tool returning a stale cache one node earlier than anyone suspected. Time-to-diagnosis went from "weeks of guessing" to one afternoon - not because anyone got smarter, but because the failure finally sat still.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Official sources covered

This track teaches from the official docs and the free LangChain Academy curricula (login required for lesson content; certificates stay with the Academy - all free). This page covers:

LangGraph HITL + interrupt docsPart 1 + Demo 1 · interrupt_before, get_state, update_state, invoke(None), dynamic interrupt + Command(resume)
Academy: Intro to LangGraph (M3)Whole session · breakpoints, state editing, approve/edit/reject on the running project
LangGraph persistence docs (time travel)Part 2 + Demo 2 · get_state_history, replay from checkpoint config, fork via update_state
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Which actions deserve an approval gate?

Blanket gating trains reviewers to rubber-stamp; no gating is the Replit incident. The rule is economic: gate where mistakes cost more than waiting does.

2 · A run is paused at interrupt_before=["save_report"]. What is the resume-as-approved idiom?

invoke(None, config) means "continue exactly where the thread paused". Editing first is update_state then the same call; rejecting is simply never making it.

3 · Forking a thread from a past checkpoint...

Git for conversations: update_state on a past checkpoint's config returns a branch config. Two futures, one preserved past - the debugging superpower of the session.

Builder session 7 cheat sheet · pin this

The gating ruleGate where mistake cost > delay cost: irreversible actions, external sends, spend. Show the ACTUAL pending action.
Static gatecompile(checkpointer=..., interrupt_before=["node"]). Pauses BEFORE the node, state saved, no timeout.
The three verbsApprove = invoke(None, cfg) · Edit = update_state(cfg, {...}) then resume · Reject = never resume.
Dynamic gateinterrupt(payload) inside a node when THIS run needs a human; resume with Command(resume=...).
Two altitudesHumanInTheLoopMiddleware at create_agent level, interrupts at graph level - same checkpoint machinery.
Time travelget_state_history = log · invoke(None, past.config) = replay · update_state(past.config) = fork. Git for conversations.
Debug workflowReproduce by replay → bisect by earlier checkpoints → fork the fix → original preserved as evidence.
Running projectDataDesk v4: asks permission before it writes. Next: b8 teaches it to actually answer the docs route - RAG.