LangGraph, Claude, deterministic safety checks

An agent loop with a code-based critic

A training-plan agent that checks its own plan before you see it

Run on its own three-runner safety panel (five plans), the critique and revise loop moved the share of plans passing all four safety checks from 1 in 5 to 2 in 5, and one of those two came back 18 weeks long for a runner with 10. Each plan cost $1.19 at API prices and took 172 seconds. In the recorded run below, asked for a 3:05 Boston off 22 miles a week, it recommended 3:20 and its critic caught five problems in the first draft. The agent is a LangGraph state machine with five nodes. It judges whether the goal is realistic, drafts a periodized week-by-week plan, and tailors it to the race and the forecast heat. Then deterministic safety checks in Python run alongside an LLM review, and a revise node fixes whatever they flag and loops back, up to a revision budget.

5 nodes

assess, plan, adjust, critique, revise, with a conditional edge from critique back through revise.

4 checks

in plain Python: week-over-week ramp above 20%, race week above 75% of peak, long run over 22.5 miles, long run longer than the week.

3:05 to 3:20

The goal it was given and the target it recommended for a 16-week build from 22 miles a week.

5 issues

caught by the critique node in that run's first draft, fixed in one revision, then a clean re-check.

At a glance

Task
Write a week-by-week marathon plan for a runner, then critique and revise it until it passes
Eval set
The three synthetic runners fixed in src/eval.py (two aggressive, one realistic); five plans, Boston and New York run twice
Baseline
The first draft, before the loop: 1 of 5 pass all four safety checks, 6 issues in all
Result
After the loop: 2 of 5 pass, 5 issues. One of the two is 18 weeks long for a 10-week runner
Cost per plan
$1.19 median API-equivalent ($0.98 to $1.38, n = 5), 6 to 8 Claude CLI calls on Opus 5.5
Latency
172 s median, 179 s p95 (n = 5, Apple M4, 2026-11-05)
Revision loops
1.8 per plan on average: four plans used both revisions, one used one
Main failure
Revisions don't reliably fix what the checks flag: of the 4 plans whose draft failed a check, 3 still failed one at the end. The LLM review raised issues of its own in 11 of 14 rounds

Why a graph

A single prompt can't check its own arithmetic

Ask a language model for a sixteen-week marathon plan and the result looks right, even when it raises mileage 30% in a week, skips the taper or prescribes a 26-mile long run. A coach would spot those errors at a glance; in fluent prose they are easy to read past.

The graph adds a place to catch them. The critique node runs _safety_checks, which reads the plan's numbers and returns a list of problems, and merges that with issues an LLM reviewer raises. _route sends the plan to revise when there are issues and revisions remain (two by default), and ends the run otherwise.

assess
Judges whether the goal is realistic for the runner's mileage, longest run and weeks to go; returns a recommended goal time and a peak weekly mileage as JSON.
plan
Drafts the Base, Build, Peak and Taper weeks in a fixed JSON schema: miles, long run, quality sessions, focus.
adjust
Rewrites workout emphasis for the race using src/knowledge.py: a simulated course penalty against Berlin, about 1 minute per °F above 55°F, and the 30 km wall.
critique
Deterministic checks plus an LLM review; the plan passes only with zero issues.
revise
Fixes the listed issues and returns the full plan to critique.

A recorded run

Asked for 3:05, it recommended 3:20

From examples/sample_run.md: Boston, goal 3:05, 16 weeks, 22 miles a week now, longest recent run 9 miles, 5 days a week, 74°F forecast. The assessment it wrote:

A 3:05 marathon (~7:03/mi) typically requires a 40-55 mi/week base with
long runs already near 16-18 miles; starting from 22 mi/week and a 9-mile
longest run leaves too little runway in 16 weeks to safely build both the
mileage and the speed. Targeting 3:20 is an aggressive-but-achievable
stretch that lets you peak around 45 mi/week without high injury risk.

The agent trace from the same run:

  • assess → feasible=False, target 3:20:00, peak 45 mpw
  • plan → drafted 16 weeks
  • adjust → tailored plan to course & weather
  • critique → 5 issue(s): week 5 jumps mileage >20% (23→28)
  • revise #1 → addressed 5 issue(s)
  • critique → clean

Output of src/cli.py, quoted verbatim apart from line breaks and two status symbols left out. The trace shows only the first of the five issues; the run didn't record the other four or the first draft.

Measured

Five plans through the loop

eval/measure.py runs the three runners in src/eval.py through the same node sequence the agent uses and scores the raw draft (out of adjust) and the final plan with the agent's own _safety_checks. It ran each runner twice and stopped starting new plans after 34 CLI calls, so Boston and New York ran twice and Berlin once. Every draft and final plan is saved in eval/runs.json.

RunnerDraft issuesFinal issuesRevisionsWhat happened
Boston 3:00, 12 wk212The week 5 and 8 jumps were fixed and a new one appeared at week 9 (25 to 33 miles)
Boston 3:00, 12 wk222Both jumps survived two revisions almost unchanged
New York 3:30, 10 wk101Passed, but the revision returned 18 weeks; no check compares the length with the 10 asked for
New York 3:30, 10 wk122Worse: one jump became two (33 to 42, 38 to 50), and week 1 rose to 32 miles for a runner at 25
Berlin 3:10, 18 wk002The draft already passed; revision 1 added four ramp jumps and revision 2 took them out, while the LLM review kept listing issues

Issues here are the four deterministic checks only. The critique node also merges in the LLM review, which raised issues of its own in 11 of 14 rounds; between the two, four of five plans ran out of revisions. The assessment is not stable either: the same Boston runner was told 3:45 in one run and 4:15 in the other. Outcomes are hand-coded in eval/error_analysis.json.

Cost per plan
$1.19 median, $0.98 to $1.38, mean $1.18 (n = 5). This is the API-equivalent total_cost_usd that claude -p --output-format json reports, on Opus 5.5, the CLI's default; it runs on a subscription, so nothing was billed
Latency
172 s median, 179 s p95 per plan (n = 5) on an Apple M4, 2026-11-05. Median per call by node: revise 32 s, plan 23 s, adjust 20 s, critique 15 s, assess 6 s (eval/calls.csv)
Loops
9 revisions over 5 plans, 6 to 8 LLM calls per plan, 38 in all

Try the critic

Edit the plan and watch the checks fire

The chart holds the final plan from that run. The four checks below are _safety_checks from src/graph.py, ported line for line to JavaScript, so they run in your browser as you change a week. The LLM half of the critique isn't here; it needs a model call.

The recorded plan passes all four checks

Weekly miles as bars, with the long run as the narrow inner bar and the 22.5-mile long-run cap as a dashed line. Click a week, or focus the chart and use the arrow keys, then change it with the sliders.

Week 13PEAK

Plan from examples/sample_run.md. Messages read as _safety_checks returns them, with a colon where the source has a dash. The three presets are edits for trying the checks, not plans the agent produced.

The plan peaks at 57 miles in week 13, above the 45 the assessment suggested, and no check compares the two; the ramp rule only looks one week back. The Boston-specific work comes from adjust: downhill quad conditioning from week 3, Newton-style hill repeats in week 6, and a 19-mile race simulation in week 12 with the hills placed at miles 15 to 19.

WkPhaseMilesLongFocus and key sessions (verbatim)
1Base2810Establish aerobic base and early heat/hydration habits; keep non-long days truly easy, 4x strides on easy day; easy aerobic run + hydration routine practice
2Base3012Build easy volume; begin heat acclimation on warmer runs, 6x100m strides; 5 mi steady on rolling terrain
3Base3213Introduce MP feel and even-effort pacing; prime quads for downhills: 4x1 mi @ marathon pace (~7:38, controlled start); gentle downhill strides
4Base2911Step-back recovery week; light quad conditioning, easy run + strides; short downhill drills
5Build3414Add threshold work; drill controlled, even pacing with easy days between hard efforts, 5x1000m @ 10K pace; 6 mi @ MP with even splits
6Build3815Boston-specific hill strength and quad durability on descents, 4 mi tempo; rolling Newton-style hill repeats (up + down)
7Build3513Step-back recovery week; reinforce fueling/hydration on-the-move, 3 mi tempo; downhill strides + hydration practice
8Build4116Sharpen speed; practice MP under fatigue with an easy day flanking the long run, 6x800m @ 5K pace; 16 mi long run w/ 4 @ MP in final miles
9Build4517Build downhill/quad resilience; carry fuel + fluids on long run, 5 mi tempo; sustained downhill running practice for Newton descents
10Peak4115Step-back before peak; calibrate hot-day pace band vs. Goal effort, 4 mi tempo; hot-day pace-band run (rehearse ~74F effort)
11Peak4818Peak endurance; only one hard quality day plus the long run, with genuine easy running between, 2x2 mi @ threshold (easy days on either side); 18 mi w/ 5 @ MP in miles 12-17
12Peak5319Simulate Boston profile, fueling, and heat; keep midweek relaxed so the long run stays the week's hardest effort: 5 mi steady (moderate, not hard); 19 mi race sim: downhill start, Newton hills at ~15-19 mi
13Peak5720Peak dress rehearsal at higher aerobic volume; long run ~35% of week, surrounded by easy days, 4 mi tempo (buffered by easy days); 20 mi w/ 6 @ MP over Newton sim + full race-day fueling
14Taper4416Begin taper; hold sharpness and quad readiness, keep heat exposure, 4 mi @ MP; short downhill hill strides
15Taper3312Reduce volume, stay fresh; lock in controlled-start pacing plan, 3 mi @ MP (rehearse conservative early pace); 6x strides + light heat exposure
16Taper229Race week: rest, fuel, hydrate; even-effort start, respect Newton hills and heat, adjust 3:20 goal to hot-day band, 2 mi @ MP + strides; shakeout jog + hydration/fuel check

The recorded plan, verbatim. Bars scale to the 57-mile peak.

Interfaces

CLI and a Streamlit app

python -m src.cli takes flags or prompts for them and prints the assessment, the plan table and the trace. streamlit run app.py wraps the same graph: set the race in the sidebar and watch the run stream through the nodes, then read the plan table, a weekly-mileage chart, the safety verdict and the trace. Generation uses the Claude CLI by default or the Anthropic API with ANTHROPIC_API_KEY; CLAUDE_MODEL can pick a faster model for the several calls a run makes. There is no hosted demo.

Limitations

Five plans, and the loop barely helps

The measured panel is five plans from three synthetic runners, so 1 in 5 against 2 in 5 is not a stable rate, but the failures in it are real: revisions that leave a flagged jump in place, add new ones, or change the plan's length. The recorded run in which the critic caught five issues and fixed them in one revision is the good case, not the typical one.

The deterministic checks cover four rules. Anything else, such as too many hard days or a monotonous build, depends on the LLM reviewer. The plan is generated advice for an adult runner and is no substitute for coaching or medical guidance.

The course figures in src/knowledge.py are simulated. They come from an earlier version of my Majors course-difficulty simulation, which ran on a simulated field with course penalties planted in code, so they are not measurements of the courses, and they differ from the current study: the file has London at 28 s and Tokyo at 33 s against Berlin and a Boston index of 1.036, where the study's paired method gives 30.7 s, 21.0 s and a composite of 1.0092. The file says so in a comment, and the course line it passes to the adjust prompt now calls the figure a simulated estimate, not a race result. They only steer the wording of adjust, and none of the safety checks reads them.

2026-10-28: the label was added after the recorded run shown on this page. The values are unchanged.

Stack

What it runs on

Agent
LangGraph StateGraph with a typed state and a conditional edge for the revise loop
Model
Claude via the Claude CLI or the Anthropic API
Guardrails
_safety_checks in src/graph.py; JSON extraction from model output in src/util.py
Domain data
src/knowledge.py: course penalties (simulated), heat tax, the 30 km wall, negative-split rate
UI
CLI and Streamlit, with a weekly-mileage chart
Related
Marathon strategy RAG, built on the same findings

A plan that was checked before it was shown