ONE CLAIM · 6 LAYERS · 38 SPANS · 11 TOOL CALLS · 99 MINUTES PAUSED

The AI stack:
how one claim crosses six layers

CLAIM 8842 · TUESDAY 2026-07-28· 6 LAYERS ·
LTHE JOBTHIS SYSTEM USES
6schedules, backfills, batch·····································Airflow
5datasets, judges, ship gates·····································Langfuse datasets
4traces, tokens, cost, latency·····································Langfuse
3a written agent loop, tools, permissions·····································Claude Agent SDK
2state, branches, pause and resume·····································LangGraph
1providers, retries, typed output, RAG·····································LangChain
0one HTTP request to a model·····································the Anthropic SDK
one claim · 38 spans · 12.6 s of compute · $0.14 · 1 human approval

An AI stack is the set of libraries that sit around a model call. The call itself is one HTTP request. A prompt goes out and text comes back, and the model keeps nothing in between. The seven rows above are what one insurance company put around that request, and each row owns exactly one job. This tutorial takes them in order, on a single claim, using real Python and real package names.

Each step carries a diagram, and most carry a code sample. open a short definition.

00

What an AI stack is made of

An AI stack is the set of libraries that sit around a model call. The call itself is one HTTP request. A prompt goes out, text comes back, and the model remembers none of it afterwards. Everything above that request is there because it has to run 4,000 times a night, on documents too large to send, with a person approving some of the answers. This chapter names the pieces the rest of the tutorial assumes you are already holding.

STEP 01 / 05
ONE HTTP REQUEST, AND WHAT COMES BACK
a model call, in full · one request, one response
REQUEST
POST
/v1/messages
model
claude-haiku-4-5
prompt
18,400 tokens
RESPONSE
status
200 OK
text back
2,600 tokens
kept for later
nothing
1.6 s · 3.1 cents · no memory of the last call and none of the next

A is one HTTP POST to a single endpoint. The request carries a prompt and the name of a model. The response carries text and a count of , the chunks of about three quarters of a word each that everything on the bill is measured in. The model keeps nothing between one call and the next. Every layer above this request exists because of that last sentence.

LAYER 0, WITH NOTHING AROUND IT
import anthropic

client = anthropic.Anthropic()

# One HTTP POST. The prompt goes out, text comes back, the model keeps nothing.
message = client.messages.create(
    model="claude-haiku-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": claim_text + policy_clause}],
)

print(message.content[0].text)        # 'Deny. Clause 14.2(b) excludes prior water damage.'
print(message.usage.input_tokens)     # 18400
print(message.usage.output_tokens)    # 2600
# 18,400 in at $1/M plus 2,600 out at $5/M is 3.1 cents, the whole bill for one claim.
the model keeps nothing between calls, so deciding what to send in the next one is somebody else’s job, and that somebody is the stack
STEP 02 / 05
A LAYER OWNS ONE JOB, AND NEVER THE MODEL
six libraries · one job each · none of them the model
A LAYER OWNS
which text the request carries
which request runs next
what is written down in between
what happened, and what it cost
A LAYER NEVER OWNS
·how good the answer is
·what the model knows
·how the model reasons
·anything inside the 1.6 seconds
every layer is a question about the 4,000th run, not the first

A is one library that owns one job about running that request many times. Layer 1 chooses which text the request carries. Layer 2 decides which request runs next and where the answer is written down. Layer 3 handles a step whose number of calls nobody knows in advance. Layers 4, 5 and 6 cover what happened, whether a change helped, and what starts at 02:00. None of the six makes the model better at reading a policy.

a layer is not a wrapper around the model; it is a wrapper around the fact that the model gets called 4,000 times tonight
STEP 03 / 05
THE CLAIM, 09:41:02 TO 11:20:41
claim 8842 · Tuesday 2026-07-28 · one run, start to finish
09:41:02claim-8842.pdf lands in a bucket, 31 pagesL1
09:41:048 passages found in 40,000 pages of policyL1
09:41:07deny, on clause 14.2(b), estimate $12,400L2
09:41:09over the $10,000 threshold, run ends, state savedL2
11:20:14the adjuster answers, the run picks up againL2
11:20:41decision written back to the claims databaseL3
99 min 5 s paused · 12.6 s of compute · 38 spans · $0.14

One event runs through every chapter. On Tuesday 28 July 2026 a file called claim-8842.pdf lands in a storage bucket at 09:41:02. It is 31 pages, the repair estimate is $12,400, and this insurer needs a person to approve anything over $10,000. The decision is written back at 11:20:41. Of those 99 minutes and 39 seconds, 12.6 seconds used a processor and the rest was spent as a row in a database. The whole thing cost 14 cents.

the claim spends 99 of its 100 minutes as a row in Postgres, and the six layers exist mostly to make those 99 minutes free
STEP 04 / 05
A SPAN, A TRACE, AND WHAT GETS COUNTED
one trace · a tree of spans · 5 of the 38 shown
TRACE claim-884238 spans · 12.6 s
ingest.pdf·······························0.9 s
retrieve·······························1.4 s
│ ├embed.query·······························0.2 s
│ └vector.search·······························0.6 s
decide·······························1.6 s
a span carries a name, a parent, two timestamps and a bag of attributes

A is one timed operation inside a run. It carries a name, a parent, a start and an end time, and a set of attributes. A is the whole tree of spans for one run. Both come from OpenTelemetry, which was built for microservices years before anyone pointed it at a model. Claim 8842 emits 38 spans, made of 7 from layer 1, 9 from layer 2 and 22 from layer 3. That count is the number this tutorial tracks, and until chapter 05 nothing stores a single one of them.

the unit this tutorial counts in was designed for microservices, and it is the only reason anyone can say which of six layers spent 84% of the money
STEP 05 / 05
SIX PIP INSTALLS, AND WHO MAINTAINS THEM
who operates each layer, and what running one costs you
LTOOLMAINTAINED BYLICENCETO RUN ONE
6AirflowApache FoundationApache-2.0docker compose
4·5LangfuseLangfuse / ClickHouseMITdocker compose
3Claude Agent SDKAnthropicMITan API key
2LangGraphLangChain Inc.MITpip install
1LangChainLangChain Inc.MITpip install
0Anthropic SDKAnthropicMITan API key
every one of them is open source; only the model call bills you

Every layer here is a package you can install this afternoon. LangChain and LangGraph are MIT licensed and maintained by LangChain Inc. Langfuse is MIT licensed, was founded in 2023 and was bought by ClickHouse in January 2026; self-hosting it is a docker compose file rather than an enterprise conversation. Airflow belongs to the Apache Software Foundation. The Anthropic SDK and the Claude Agent SDK come from Anthropic, and those two need an API key, because a model call is the only part of this stack that bills anybody.

THE WHOLE STACK, AS INSTALL LINES
# Layer 0: the HTTP client for the model
pip install anthropic

# Layers 1 and 2: providers, typed output, retrieval, the state graph
pip install langchain langchain-anthropic langgraph langchain-postgres

# Layer 3: the agent loop that runs Claude Code, as a library
pip install claude-agent-sdk

# Layers 4 and 5: traces, datasets, scores
pip install langfuse

# Layer 6: the scheduler
pip install apache-airflow

# Five of the six are open source and run on one laptop. Only the model
# call needs an account:
export ANTHROPIC_API_KEY=sk-ant-api03-EXAMPLE
all six layers are open source and run on one laptop; the only line item is the model call at the bottom
deep dive: what a layer costs before it does anything

What a layer costs before it does anything. An import is the cheap part. Every tool the agent in layer 3 may call sits in the system prompt as a schema, on every turn, whether or not it gets used. Every layer is also one more package to pin, upgrade and read the changelog of. The six here add roughly 40 transitive dependencies to a script that had one.

Framework and layer are different words. A framework is a package you install. A layer is a job that has to be done by something. LangChain 1.0 and LangGraph 1.0 are two packages covering two layers, and they shipped on the same day from the same release, because the second is the runtime the first runs on.

Where 38 comes from. Layer 1 emits 7 spans per claim: extract, embed, search, rerank, call, parse, retry. Layer 2 emits 9, one for each node entered and each checkpoint written. Layer 3 emits 22, one per tool call plus the model turns between them. That is 38 a claim and 152,000 a night, and every figure later in the tutorial is built on those three numbers.

Nobody designed this stack. Airflow was started in 2014, LangChain in 2022, Langfuse in 2023, LangGraph in 2024, the Claude Agent SDK in 2025. Each arrived for a reason that had nothing to do with the others, and the six ended up in one requirements file because one Tuesday needed all of them.

01

Sixty-two lines that already work

Sixty-two lines of Python, one dependency, and it decided claim 8842 correctly on the first try. It reads the PDF, pastes the text into one model call, prints a decision, and exits. That took 8.4 seconds and cost 3.1 cents. It is also the version that cannot survive tonight, and this chapter names the reasons one at a time. Layers so far: 0. Spans in the trace: 1.

STEP 01 / 05
SIXTY-TWO LINES, ONE DEPENDENCY
version one · one import · it decides claim 8842 correctly
01import anthropic
08text = extract_pdf("claim-8842.pdf")
14clause = open("clause-14-2b.txt").read()
22msg = client.messages.create(
27 messages=[{"role": "user", ...}])
41decision = json.loads(msg.content[0].text)
55print(decision["pay"], decision["clause"])
62# that is the whole program
1 span
8.4 s · 18,400 tokens in · $0.031 per claim

The whole first version: open the PDF, extract 31 pages of text, paste it and one hand-picked policy section into a single messages.create call, print the decision, exit. One import. It runs in 8.4 seconds on 18,400 input tokens and costs 3.1 cents. It is correct on claim 8842, and correct on the next forty. Nothing in this tutorial is here because that script is bad.

the first version of every AI feature is a script that works; the whole stack exists because of the 4,000th run, not the first
STEP 02 / 05
THE POLICY IS 40,000 PAGES
what fits in one request · what the policy actually is
40,000 pages ÷ what fits:130× too big

The policy section was picked by hand, and that is the part which does not survive. The full policy set is 40,000 pages, about 26,000,000 tokens at 650 tokens a page. A is the largest number of tokens one request may contain, and the biggest on the price list is 200,000. That holds 0.8% of the policy set, so the corpus is 130 times too large to paste. The model is not the constraint here. Somebody has to choose eight paragraphs out of 40,000 pages before the model is called at all.

the policy set is 130× larger than the biggest context window on the price list, so somebody has to choose what the model reads
STEP 03 / 05
JSON, MOST OF THE TIME
the same prompt, two answers · json.loads decides which
run 1,431
{"pay": 12400,
"clause": "14.2(b)"}
parsed ✓
run 1,432
Based on the estimate,
{"pay": 12400, ...}
JSONDecodeError
1 run in 50 ·80 crashes a night at 4,000 claims

The script asks for JSON in the prompt and calls json.loads on whatever comes back. About one run in fifty returns a sentence before the opening brace, or a trailing comma, and throws. At 4,000 claims a night that is 80 crashes. A better prompt does not fix this. does, and it means constraining generation with a schema instead of asking politely: the model is handed a shape and can only emit something that fits it. Every vendor supports this and every vendor spells it differently.

THE PARSE THAT THROWS, AND THE SCHEMA THAT CANNOT
import json

# The script's version: ask for JSON in the prompt and hope for JSON back.
reply = client.messages.create(model="claude-haiku-4-5", max_tokens=1024, messages=prompt)
decision = json.loads(reply.content[0].text)  # JSONDecodeError on ~1 run in 50

# The version that cannot throw: the shape is a schema the model is made to fill.
RECORD = {
    "name": "record_decision",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {"approve": {"type": "boolean"}, "clause": {"type": "string"}},
        "required": ["approve", "clause"],
        "additionalProperties": False,
    }}
reply = client.messages.create(
    model="claude-haiku-4-5", max_tokens=1024, messages=prompt, tools=[RECORD],
    tool_choice={"type": "tool", "name": "record_decision"})
decision = reply.content[0].input  # {'approve': False, 'clause': '14.2(b)'}
prompting for JSON fails on about one run in fifty, which is fine on a laptop and 80 crashes a night in production
STEP 04 / 05
529, AND THEN 429
the retry the script does not have · bar length is the wait
attempt 1529 overloaded
wait 1 s
attempt 2429 rate limited
wait 2 s
attempt 3529 overloaded
wait 4 s
attempt 4200 ok
done
backoffjittera capan idempotency key4 decisions, 15 s worst case

Providers answer 529 when they are overloaded and 429 when the caller is over its rate limit, and the script handles neither. A safe retry here is four decisions rather than one line. Wait longer after each failure. Add jitter, so 4,000 clients do not all come back at the same instant. Cap the number of attempts. Send an , a stable id the provider matches repeat requests against, so a retried claim is never paid twice. Four attempts at 1, 2, 4 and 8 seconds put 15 seconds between a bad minute and an answer.

a retry that is safe to write yourself needs backoff, jitter, a cap, and an idempotency key, which is four decisions and not one line
STEP 05 / 05
SIX THINGS THE SCRIPT CANNOT DO
six jobs the script cannot do · one column still empty
6schedules, backfills, batch·············
5datasets, judges, ship gates·············
4traces, tokens, cost, latency·············
3a written agent loop, tools·············
2state, branches, pause, resume·············
1providers, typed output, RAG·············
0one HTTP request to a modelthe Anthropic SDK

The roadmap, as a list rather than a promise. It cannot search 40,000 pages. It cannot stop for a human and start again tomorrow. It cannot run a step whose length nobody knows in advance. It cannot tell you what happened at 03:14. It cannot prove that a prompt change helped. It cannot run at 02:00 on its own. Six gaps, six layers, and each one is a different category of tool.

not one of the six layers makes the model better; every one of them exists for what happens when nobody is watching
deep dive: what the 62 lines actually cost

The token bill of the naive version. 18,400 input tokens per claim across 4,000 claims is 73.6 million tokens a night, and that is with the policy clause hand-picked. Pasting the whole policy set is not expensive, it is impossible: 26,000,000 tokens do not fit in a 200,000-token request at any price.

Why a bigger window is not the answer. Window sizes keep growing, and recall inside a long window does not grow with them. A model handed 400,000 tokens of policy is measurably worse at finding clause 14.2(b) than a model handed the eight paragraphs that contain it. The arithmetic on price per token is in the AI numbers tutorial.

The honest defence of the script. Under about 50 runs a day, with a person reading every output, none of the six layers pay for themselves. The retry is a try/except, the trace is a print statement, and the eval set is whoever is reading. This is why engineers who insist frameworks are overhead are usually describing a real system they really ran.

02

The layer that talks to models

Two problems arrive together at layer 1. Four vendors spell the same request four different ways, and the policy set is 26,000,000 tokens against a 200,000-token limit. LangChain is one answer to both, and it was not a well-liked one for three years. Version 1.0 shipped on 22 October 2025 by moving most of the library out into a package called langchain-classic, and what is left is the message format, the tool schema, the retry policy and the retrieval pipeline. The claim goes from one span to seven.

STEP 01 / 05
ONE MESSAGE FORMAT, FOUR WIRE FORMATS
one message object · four wire formats it hides
ChatModel
one string to swap
Anthropic
Bedrock
Vertex
OpenAI
THEY DISAGREE ABOUT
system prompt placement
tool-call encoding
streaming event names
token count fields
retry and error codes
LiteLLM does this one job, and nothing else

The part that earns its place is dull and load-bearing. One message object, whatever the vendor. One tool-schema format. One retry and timeout policy. And a , meaning a single interface over several model vendors, so swapping Anthropic for Bedrock or Vertex is a change of string rather than a rewrite. Vendors disagree about where the system prompt goes, how a tool call is encoded, what the streaming events are called, and which field holds the token count. That disagreement is about 300 lines of code, and this is where it lives. LiteLLM does this one job and nothing else.

THE VENDOR, AND THE ANSWER’S SHAPE, IN ONE CALL
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field

class Decision(BaseModel):
    approve: bool = Field(description="True when the claim is payable as filed")
    clause: str = Field(description="The policy clause the decision rests on")

# The vendor is one string. Bedrock and Vertex are this line, edited.
model = init_chat_model("anthropic:claude-haiku-4-5", max_retries=4, timeout=60)
# model = init_chat_model("bedrock_converse:anthropic.claude-haiku-4-5")

# Typed output happens inside the same call, not in a second one.
decision = model.with_structured_output(Decision).invoke(prompt)
print(decision.approve, decision.clause)  # False 14.2(b)
swapping a model provider is a one-string change here, and about 300 lines of encoding differences underneath it
STEP 02 / 05
40,000 PAGES BECOME 81,000 CHUNKS
the policy set, once, at build time · then eight chunks per claim
40,000pages
26,000,000tokens
81,000chunks
0.5 GBindex
3,200 tokensin the prompt
26,000,000 ÷ 3,200 =8,125× less text per claim

The other half of the layer is loaders, splitters, an and a vector store. An embedding is a list of numbers standing for a piece of text, arranged so that texts about the same subject land near each other. The 40,000 pages are cut into of about 400 tokens with 80 tokens of overlap, which gives roughly 81,000 of them. At 1,536 numbers per chunk and 4 bytes per number, the that answers “which of these are nearest to my question” is half a gigabyte. Retrieving the nearest 8 puts 3,200 tokens in the prompt instead of 26,000,000. Anthropic ships no embedding model, so this is where a second vendor turns up.

40,000 PAGES IN, EIGHT PARAGRAPHS OUT
from langchain_openai import OpenAIEmbeddings
from langchain_postgres import PGVector
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 400-token pieces with 80 tokens of overlap, counted here in characters.
splitter = RecursiveCharacterTextSplitter(chunk_size=1600, chunk_overlap=320)
chunks = splitter.split_documents(policy_pages)  # 40,000 pages become 81,043 chunks

# Anthropic ships no embedding model, so this layer talks to a second vendor.
store = PGVector(
    embeddings=OpenAIEmbeddings(model="text-embedding-3-small"),  # 1,536 dimensions
    collection_name="policy",
    connection="postgresql+psycopg://localhost:5432/claims",
)
store.add_documents(chunks)  # 81,043 x 1,536 x 4 bytes = 0.50 GB in Postgres

passages = store.similarity_search("prior water damage exclusion", k=8)
# 3,200 tokens go into the prompt instead of 26,000,000
retrieval puts 3,200 tokens in the prompt instead of 26,000,000, and the whole index of 40,000 pages fits in half a gigabyte
STEP 03 / 05
NINE DAYS IN OCTOBER 2022
a side project, then the default · nine days at the left edge
140,000 GitHub stars · about 90 million package downloads a month

The library doing those two jobs has a longer history than the jobs do. Harrison Chase wrote the first version between 16 and 25 October 2022, as a side project, six weeks before ChatGPT existed. By mid-2026 the repository has passed 140,000 stars and the Python and JavaScript packages average around 90 million downloads a month. The reputation for heavy abstractions and the download count are both real, and they are about different things. The first is a statement about the 2023 API. The second is about who is still shipping on it.

LangChain was written in nine days in October 2022, six weeks before ChatGPT existed
STEP 04 / 05
1.0 SHIPPED BY DELETING
what the package exported, before and after 2025-10-22
BEFORE · langchain
chainsagents (legacy)memoryindexesretrieversdocument_loadersllmschat_modelstools
langchain-classic
AFTER · langchain 1.0
chat_modelsmessagestoolscreate_agentmiddleware
no breaking changes promised until 2.0

Version 1.0 arrived on 22 October 2025 and cut the package down, moving the rest to langchain-classic. create_agent replaced the old prebuilt agent and runs on the LangGraph runtime. Typed output moved into the agent loop itself, so a structured answer no longer costs a second model call. Middleware landed for human approval, summarization and PII redaction. Python 3.10 is the floor, and the promise is no breaking changes until 2.0.

the most-downloaded LLM framework shipped its 1.0 mostly by moving code out of itself into a package named classic
STEP 05 / 05
ONE SPAN BECOMES SEVEN
one operation became seven · none of them visible yet
before
1 span
1pdf.extract
2embed.query
3vector.search
4rerank
5chat.completion
6parse.structured
7chat.completion.retry
after
7 spans
wall clock 8.4 s → 5.2 s · places to be wrong 1 → 7

The bill for layer 1 is that the claim now costs seven operations instead of one. Extract the PDF, embed the question, search the index, rerank, call the model, parse the typed answer, retry once. Wall clock drops from 8.4 to 5.2 seconds because the prompt got smaller. There are now seven places to be slow and seven places to be wrong, and no way to look at any of them. Layers: 1. per claim: 7. Spans you can read: still 0.

the layer that saved 200 lines of provider code also turned one thing that could fail into seven
deep dive: which package owns which job

The unbundled version of this layer. LiteLLM for provider routing only. Instructor or BAML for typed output only. LlamaIndex for document ingestion and indexing. Haystack as the older full-pipeline option. A 2026 stack built from four narrow libraries instead of one wide one is a normal choice, and the trade is more imports against fewer surprises.

What the abstraction complaint was actually about. The old high-level chains assembled the prompt for you and did not show it, so debugging a bad answer meant reading library source to find out what was sent. create_agent plus middleware makes both the prompt and the loop inspectable, which is the specific thing that changed.

Which package is which. langchain-core holds the interfaces and depends on almost nothing. langchain holds the agent and middleware. Provider packages such as langchain-anthropic hold one vendor each. langchain-classic holds everything 1.0 stopped shipping.

Where the retrieval detail lives. Chunk sizes, overlap, hybrid search, reranking and citation are the subject of the RAG tutorial. The boring default for the store is pgvector, because the policy index is 0.5 GB and the team already runs Postgres.

03

State, branches, and a run that stops for a person

Three things do not fit inside a function call. A branch that depends on the model’s answer. A step that waits 99 minutes for a person in a different building. A crash at second 8 of a 12-second run that you would rather not pay for twice. LangGraph answers all three by treating the feature as a state machine with a durable store behind it. The claim goes from seven spans to sixteen, and nine of the new ones are bookkeeping.

STEP 01 / 05
NODES, EDGES, AND ONE TYPED DICT
the claim graph · six nodes, one conditional edge
ingestretrievedecidethreshold
↙ amount ≤ $10,000amount > $10,000 ↘
pay
await_approvalinvestigate
state: {claim, clause, amount, decision} · nodes return only what they changed

A is a program written as three declarations. First a typed state object, which is the only thing that travels between steps. Then , plain functions that take the whole state and return just the part they changed. Then edges, which decide what runs next. Conditional edges branch on the state, which is how “$12,400 is over $10,000” becomes routing instead of an if-statement buried inside a function. The claim graph has six nodes: ingest, retrieve, decide, threshold, await_approval, investigate. LangGraph appeared in early 2024 and reached 1.0 in October 2025.

THE $10,000 THRESHOLD, WRITTEN AS AN EDGE
from typing import TypedDict
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.graph import END, START, StateGraph

class ClaimState(TypedDict):   # the only thing that travels between nodes
    claim_id: str
    amount_cents: int
    decision: str

def over_threshold(state: ClaimState) -> str:
    # "$12,400 is over $10,000" is routing here, not an if-statement inside a node.
    return "await_approval" if state["amount_cents"] > 1_000_000 else END

builder = StateGraph(ClaimState)
builder.add_node("decide", decide)                  # each node is an ordinary function
builder.add_node("await_approval", await_approval)
builder.add_edge(START, "decide")
builder.add_conditional_edges("decide", over_threshold)
with PostgresSaver.from_conn_string(DB_URI) as saver:
    graph = builder.compile(checkpointer=saver)     # state is written after every node
a node is an ordinary function; the framework only owns which one runs next and what is written down in between
STEP 02 / 05
THE CHECKPOINTER WRITES AFTER EVERY NODE
one write per node · then the process dies
ingestretrievedecidethreshold
CHECKPOINT STORE · THREAD claim-8842
after ingest18,400 tokens of claim text
after retrieve8 chunks, clause 14.2(b)
after decidepay $12,400
kill -9rerun starts at threshold · nothing re-paid

Persistence is what changes the range of possible failures. With a configured, the state is written to durable storage after every step, filed under a thread id. Kill the process after node 3 of 6 and the rerun starts at node 4, so the retrieval and the $0.09 decision call are not paid for twice. Three checkpoint writes show up in this claim’s trace. In-memory checkpointers exist for tests, and are exactly as durable as the process.

kill the process after the decision and the rerun costs nothing, because the answer is a row in Postgres and not a variable in a dead process
STEP 03 / 05
interrupt() AND THE 99-MINUTE PAUSE
09:41:09 the run ends · 11:20:14 it continues
PROCESS
graph.run
node.await_approval
nothing running
CHECKPOINT STORE
thread claim-8842
awaiting approval · $12,400
paused for00:00→ approved, run continues
memory used while waiting: none · connections held: none · cost: none

The approval step calls interrupt(). The state is written and the run ends, so nothing is left waiting. At 11:20:14 the approver’s decision is passed back with the same thread id, the node runs again from the checkpoint, and execution continues. From 09:41:09 that is 99 minutes and 5 seconds using no memory, no connection and no compute. Every product feature labelled “human in the loop” is this plus a queue for the person.

TWO CALLS, TWO PROCESSES, 99 MINUTES APART
from langgraph.types import Command, interrupt

def await_approval(state: ClaimState) -> dict:
    # Writes the state and ends the run. Nothing is left waiting anywhere.
    answer = interrupt({"claim": state["claim_id"], "cents": state["amount_cents"]})
    return {"decision": answer}

thread = {"configurable": {"thread_id": "claim-8842"}}

# 09:41:09. Runs as far as await_approval, then the process exits.
graph.invoke({"claim_id": "8842", "amount_cents": 1_240_000}, thread)

# 11:20:14, a different process, the same thread id. interrupt() returns "approve".
graph.invoke(Command(resume="approve"), thread)
the 99-minute wait for a human costs nothing, because nothing is waiting; the run is a row in a database until someone answers
STEP 04 / 05
A CHECKPOINT DOES NOT NOTICE
the process died at node 4 · who does what next
CHECKPOINTEDDURABLE ENGINE
stores the stateyesyes
notices the crashnoyes
retries the stepyou dothe engine
times the run outnoyes
resumes itwhen askedby itself
LangGraph, CrewAI, ADK on the left · Temporal on the right

The honest limit of this layer, and the reason the next paragraph names a different tool. A checkpoint records where the run stopped. Nothing in the graph library detects that a run stopped, retries it, or times it out: if the process dies, the state is safe and the run is simply not running until something outside notices. engines invert that. The engine owns the run, retries an activity itself, accepts a signal carrying the human’s answer, and treats a sub-agent as a child workflow.

a checkpoint tells you where a run stopped and does not notice that it stopped
STEP 05 / 05
THE SAME SHAPE UNDER SIX NAMES
who owns what runs next · six answers, one question
LangGraph·································an audit trail and an explicit graph
OpenAI Agents SDK·································a model-driven loop, 100+ models
CrewAI·································role-based prototypes, quickly
Pydantic AI·································every input and output validated
Temporal·································durability matters more than ergonomics
plain Python·································four steps that never branch
LangGraph: about 39 million PyPI downloads a month

The alternatives, each with the reason someone picks it. The OpenAI Agents SDK for a model-driven loop with 100 or more models behind LiteLLM. CrewAI for role-based multi-agent prototypes. Pydantic AI when every input and output should be a validated model. Google’s ADK for Gemini-first teams. Temporal when durability matters more than graph ergonomics. Plain Python when the graph has four steps and never branches. LangGraph’s own case is its deployment list and about 39 million PyPI downloads a month. Layers: 2. Spans per claim: 16.

the graph library, the role-based framework and the durable engine are three answers to the same question: who owns what runs next
deep dive: state machines, threads and time travel

Threads, and rewinding one. Every run is a thread of checkpoints, so a run can be rewound to an earlier step and replayed with a changed input. That is how a disputed decision gets reproduced instead of argued about: load thread claim-8842, rewind to decide, change the clause, run forward.

Reducers, and the bug they prevent. Each state key declares how updates combine: messages appends, amount overwrites. Two nodes writing the same key in one step is a real error, and the reducer is where the developer states what the framework should do about it.

Serialization is the tax. Everything in state has to be storable, which rules out open file handles, live clients and lambdas. The usual fix is to keep ids in state and look the objects up inside the node, which is more code and one fewer way to lose a run.

Why 1.0 landed on the same day. create_agent in LangChain 1.0 is a prebuilt graph running on this runtime, so the two packages are one release. Reaching for LangChain’s agent and reaching for LangGraph are the same decision at different levels of detail.

04

The part of the run you cannot draw

Node 6 is called investigate and nobody can draw it. Checking whether a repair shop has a history means reading prior claims, then possibly the adjuster’s note, then possibly nothing else, and the number of steps depends on what the first step finds. That is a loop, not a graph, and it is where the second kind of tool lives. One node adds 22 spans to this claim.

STEP 01 / 05
THE NODE NOBODY CAN DRAW
five tools, unknown order · draw it, or loop it
AS A GRAPH
prior_claimsshop_historyadjuster_notephoto_metapolicy_lookup
every path drawn in advance
5! = 120 orderings
AS A LOOP
modeltool
one shape, any length
claim 8842: 11 calls · next claim: 3
the length is decided while it runs, not before

What investigate has to do: look up prior claims on the policy, check the repair shop against past claims, read the adjuster’s note, and stop once there is enough to write one paragraph. On claim 8842 that took 11 tool calls. On the next claim it took 3. Drawing this as a graph means drawing every path in advance, and the number of paths is the number of orderings of five tools, not the number of tools. A sequence whose length is decided while it runs is an .

the same node took 11 tool calls on one claim and 3 on the next, which is why nobody can draw it in advance
STEP 02 / 05
THE LOOP IS ALREADY WRITTEN
the same split as Claude Code, as a library
WHAT YOU WRITE
the tools
the system prompt
the permissions
the hooks
WHAT SHIPS WITH IT
the agent loop
tool execution
context management
compaction
sessions
subagents
Python and TypeScript · other languages run the CLI with -p

A is a library that already contains the agent loop, the tool execution and the context management, so what you supply is tools and rules rather than control flow. The Claude Agent SDK is the harness behind Claude Code, packaged for Python and TypeScript. You supply tools, a system prompt, and a policy about what is allowed. It also loads skills, slash commands and memory from a project’s .claude/ directory, and reaches external systems over MCP. From another language, run the CLI as a subprocess with -p and JSON output.

NODE 6, WITH THE LOOP LEFT TO THE LIBRARY
import anyio
from claude_agent_sdk import ClaudeAgentOptions, query

options = ClaudeAgentOptions(
    model="claude-haiku-4-5",
    system_prompt="Check this repair shop against prior claims. Stop when one paragraph is supported.",
    allowed_tools=["Read", "Grep", "mcp__claims__prior_claims"],
    max_turns=20,
)

async def investigate(claim_id: str) -> str:
    messages = []
    async for message in query(prompt=f"Claim {claim_id}", options=options):
        messages.append(message)
    return messages[-1].result   # 11 tool calls on claim 8842, 3 on the next one

anyio.run(investigate, "8842")
you do not write an agent loop with this SDK; you write the tools and the rules, and the loop is the part that ships with it
STEP 03 / 05
PERMISSIONS ARE THE CONTROL FLOW
not what happens next · what is allowed to happen at all
AUTOASKNEVER
read prior claims··
read the adjuster note··
look up the repair shop··
email the policyholder··
write to the claims database··
PreToolUseruns your code before any call, and can refuse it

In a graph you decide what happens next. In a harness you decide what is allowed to happen at all. A marks each tool as automatic, needing approval, or unavailable. Hooks run your own code at lifecycle points, and PreToolUse is the one that matters, because it is the last place to refuse a call before it happens. In this system, reading prior claims is automatic, emailing the policyholder asks first, and writing to the claims database is not on the agent’s list at all.

THE LAST PLACE TO SAY NO, BEFORE THE CALL HAPPENS
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher

async def refuse_claims_writes(input_data, tool_use_id, context):
    # PreToolUse runs after the model has asked and before anything happens.
    if input_data["tool_input"].get("table") == "claims":
        return {"hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "deny",
            "permissionDecisionReason": "This agent never writes to the claims database.",
        }}
    return {}

options = ClaudeAgentOptions(
    allowed_tools=["Read", "Grep", "mcp__claims__prior_claims"],   # runs automatically
    disallowed_tools=["mcp__claims__write_row"],                   # not on the list at all
    hooks={"PreToolUse": [HookMatcher(matcher="mcp__claims__.*", hooks=[refuse_claims_writes])]},
)
in a harness you never write what happens next, you write what is allowed to happen, and that list is the whole safety model
STEP 04 / 05
A SUBAGENT’S TRANSCRIPT OUTLIVES THE PARENT’S
read 40 prior claims · return one line
main run
9,400
subagent
0
↩ 240 tokens: “three prior claims, same shop”
61,000 tokens read · 240 kept · the parent never saw the other 60,760

Two mechanisms keep a long run inside the window. A is a separate agent instance for one focused subtask, with its own context, so “read 40 prior claims and return one line” does not put 40 claims in the main transcript. summarizes older messages as the window fills, without being asked. The investigation read 61,000 tokens and handed back 240. Without the subagent the main run would carry all 61,000 into every later turn, and pay for them again each time.

a subagent reads 61,000 tokens and hands back 240, which is the entire reason to spawn one
STEP 05 / 05
DRAW IT, OR HAND IT OVER
layer 2 calls layer 3 from one node · 22 spans arrive
6schedules, backfills, batch·············
5datasets, judges, ship gates·············
4traces, tokens, cost, latency·············
3a written agent loop, toolsClaude Agent SDK
2state, branches, pause, resumeLangGraph
1providers, typed output, RAGLangChain
0one HTTP request to a modelthe Anthropic SDK
node 6 opens an agent session and returns a typed finding into the graph state

The rule, stated once. Known steps, an audit requirement and a typed output mean a graph. Unknown steps, open-ended tool use and an answer in prose mean a harness. This system does both. Node 6 of the LangGraph graph opens an Agent SDK session, and that session returns a typed finding back into the graph state. The alternatives at this layer are the OpenAI Agents SDK and Google’s ADK. Anthropic’s Managed Agents runs the same loop as a hosted service instead of a library. Layers: 3. Spans per claim: 38.

draw the graph when you know the steps; hand over the loop when you do not; the interesting systems do both in the same run
deep dive: what a harness owns that a graph does not

Sessions, and forking one. A session can be resumed later or forked in place, which is the cheap way to try two investigation strategies on the same claim and keep whichever one found the prior claims. Forking costs a copy of the transcript and nothing else.

The hooks people actually use. There are 25 lifecycle points and three carry most of the traffic: PreToolUse to refuse a call, PostToolUse to log what a call returned, and PreCompact to keep a copy of the transcript compaction is about to summarize.

Tool definitions cost tokens before anything runs. Every tool the agent may call sits in the system prompt as a schema, on every turn. The measured bill for one popular MCP server is in the MCP tutorial, and it is larger than most people expect.

The adjuster’s note is untrusted text. An agent that reads free-form text written by someone outside your company, and that also holds tools, is the setup described in the prompt-injection tutorial. The permission table above is the mitigation, not the prompt.

05

Thirty-eight spans nobody could see

At 03:14 last night 41 claims out of 4,000 came out wrong, which is one percent. Four layers ran on every one of them and one of the four is responsible. The runs emitted 38 spans each and nobody can read a single one. Emitting a span and storing a span are different jobs, and nothing in the stack so far does the second one.

STEP 01 / 05
THE RUN WAS ALWAYS 38 SPANS
claim 8842 · 0 to 12.6 s, excluding the 99-minute pause
graph.run
12.6 s
node.ingest
1.9 s
pdf.extract
1.6 s
node.retrieve
1.6 s
vector.search
0.7 s
node.decide
2.8 s
chat.completion
2.5 s
parse.structured
0.1 s
checkpoint.write ×3
18 ms
node.await_approval
interrupt
agent.session ×22 spans
5.4 s
spans emitted: 0 · emitted since chapter 02, stored since just now

Nothing is added to the claim’s execution in this chapter. LangChain, LangGraph and the Agent SDK have been emitting since chapter 02, and with no receiver configured they went nowhere. Turning tracing on is a client and an endpoint, not a rewrite. The waterfall for claim 8842 holds 7 spans from layer 1, 9 from layer 2 and 22 from layer 3, over 12.6 seconds end to end once the 99-minute pause is taken out.

THE ENTIRE CHANGE THIS CHAPTER MAKES
# The pipeline has been emitting spans since chapter 02 with nowhere to send them.
# This is the whole change: two keys, one endpoint, one header.

export LANGFUSE_PUBLIC_KEY=pk-lf-1a2b3c4d
export LANGFUSE_SECRET_KEY=sk-lf-5e6f7a8b
export LANGFUSE_HOST=https://langfuse.claims.internal

AUTH=$(printf '%s:%s' "$LANGFUSE_PUBLIC_KEY" "$LANGFUSE_SECRET_KEY" | base64)
export OTEL_EXPORTER_OTLP_ENDPOINT="$LANGFUSE_HOST/api/public/otel"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic $AUTH"

# No Python was edited. The next run stores 38 spans per claim, 152,000 a night.
python -m claims.nightly
the trace existed from chapter 02; what was missing was somewhere to put it, which is a client and an endpoint and not a rewrite
STEP 02 / 05
A SPAN IS A TREE NODE WITH TOKENS ON IT
one span, expanded · four names agreed across vendors
span.namechat.completion
parentnode.decide
duration2,512 ms
gen_ai.request.modelthe deciding model
gen_ai.usage.input_tokens21,600
gen_ai.usage.output_tokens96
gen_ai.response.finish_reasonstool_calls
prompt and completion text: opt-ina claim PDF in a span is a claim PDF in your vendor

What is recorded: a name, a parent, a start and end time, and attributes. The standardize the AI-specific ones, including gen_ai.request.model, gen_ai.usage.input_tokens and gen_ai.response.finish_reasons. Prompt and completion text is opt-in, because a claim PDF in a span attribute is a claim PDF in your logging vendor. As of v1.42.0 on 12 June 2026 the whole gen_ai group moved to its own repository, and it is still pre-stable.

ONE OF CLAIM 8842’S THIRTY-EIGHT SPANS
{
  "name": "chat claude-haiku-4-5",
  "trace_id": "8842f01c9b3e4a7d",
  "span_id": "c37a09e1",
  "parent_span_id": "b41d6e02",
  "start_time_unix_nano": 1785231669412000000,
  "end_time_unix_nano": 1785231671034000000,
  "attributes": {
    "gen_ai.operation.name": "chat",
    "gen_ai.provider.name": "anthropic",
    "gen_ai.request.model": "claude-haiku-4-5",
    "gen_ai.usage.input_tokens": 18400,
    "gen_ai.usage.output_tokens": 2600,
    "gen_ai.response.finish_reasons": ["end_turn"],
    "langfuse.observation.cost_details.total": 0.031
  }
}
the vocabulary that makes a LangChain span look identical to a raw API span is still officially experimental in 2026, with no 1.0 release
STEP 03 / 05
AN OTEL BACKEND WITH A PRICE COLUMN
anything that speaks OpenTelemetry · one endpoint
LangChain SDKAgent SDKraw OpenTelemetryOpenLLMetry
Langfuse
/api/public/otel
tracespromptsdatasets
MIT licensed · YC W23 · self-hosting is a normal deployment

Langfuse was founded in 2023 by Marc Klingen, Maximilian Deichmann and Clemens Rawert, went through Y Combinator’s W23 batch, is MIT licensed, and treats self-hosting as a normal deployment rather than an enterprise favour. Its own SDKs are built on OpenTelemetry and it accepts OTLP at /api/public/otel, so anything that speaks the protocol can send to it, including stacks with no LangChain anywhere in them. It also stores prompts under version control, outside your repository.

the tracing tool most teams self-host speaks plain OpenTelemetry, so the thing sending traces does not have to know it exists
STEP 04 / 05
THE COST COLUMN IS THE ONE PEOPLE OPEN FIRST
claim 8842 · where the spans and the money went
SPANSTOKENSCOST
layer 1 · retrieval and the call725,000$0.017
layer 2 · nodes and checkpoints90$0.000
layer 3 · the agent session2261,000$0.118
one claim, 12.6 s of compute3886,000$0.14
4,000 claims a night · $560 a night · $16,800 a month

A stored turns into per-run cost, p95 latency, token counts per layer, and the ability to filter to the 41 wrong claims and read them. Claim 8842: $0.14, 12.6 seconds of compute, 86,000 tokens, of which 61,000 belong to the investigation. Across 4,000 claims that is $560 a night and $16,800 a month, and layer 3 is 84% of it. Nobody guessed that split before the traces existed.

the investigation node is 22 of 38 spans and 84% of the bill, which nobody guessed before the traces existed
STEP 05 / 05
SAME JOB, FOUR VENDORS
the same job, four products · the differences that decide it
LICENCESELF-HOSTOTELEVALS
LangfuseMITnormalyesyes
LangSmithclosedenterpriseingestyes
Arize Phoenixopennormalyesyes
Braintrustclosedenterpriseingestyes
general tracing backends now read the same gen_ai attributes

Langfuse is MIT licensed with self-hosting as an ordinary deployment. LangSmith is a proprietary backend with the deepest zero-setup LangChain and LangGraph integration, and self-hosting only on enterprise plans. Arize Phoenix is open source and runs locally or in a container. Braintrust ties tracing to datasets and CI gates in one product. General tracing vendors now read the same gen_ai attributes, so a backend you have run for years may already qualify. Layers: 4. Spans you can read: 38.

if your traces already go to a general tracing backend, the AI-specific tools are competing with a system you have run for years
deep dive: sampling, PII and the cost of keeping everything

The volume, and what sampling costs. 152,000 spans a night is a small number for a tracing backend and a large one for a Postgres table. Head sampling at 10% throws away nine of every ten rare failures, which are the rows you turned tracing on for. The usual answer is to keep every error and a percentage of successes.

Content capture crosses a boundary. Span attributes holding prompts and completions carry the claimant’s name, the estimate and the adjuster’s note into whatever stores the traces. The opt-in default exists for that reason, and redaction at the SDK is cheaper than a data-processing agreement.

Prompts outside the repository. Storing prompts in the platform lets a prompt change ship without a deploy, which is the upside. The downside is that a checkout of the repository no longer reproduces production, so the version id belongs in the trace.

Why a database company bought a tracing tool. At this volume the hard part is the store rather than the UI. High-cardinality columns, cheap aggregate queries, retention measured in months. Langfuse was acquired by ClickHouse in January 2026, which is the same observation with money behind it.

06

Proving a one-word prompt change helped

A prompt change might help and might make things worse, and nothing built so far can tell the difference in advance. An engineer on the claims team proposes swapping one word, from summarize to quote, because 9 of the 41 wrong claims paraphrased a clause instead of citing it. The trace says what happened last night. It cannot say whether tomorrow gets better. Those are two questions answered from the same stored rows, which is why one product usually answers both.

STEP 01 / 04
THE DATASET IS LAST WEEK, WITH ANSWERS
180 real claims, pulled from stored traces, answers attached
41 that came out wrong139 sampled from the right onesso a fix cannot break them unnoticed
source: production traces · plus the decision that should have been made

An here is 180 real claims pulled out of stored traces with the correct decision attached: the 41 that came out wrong, plus 139 sampled from the ones that came out right, so a fix cannot break those without anyone noticing. The traces are the raw material, which is the whole reason the two products are one product. Synthetic test cases exist and are worse, because they never contain the adjuster who writes in capitals.

the eval set is last week’s production traffic with the answers filled in, which is why the tracing tool and the eval tool are the same tool
STEP 02 / 04
TWO KINDS OF SCORE
two of the three checks need no model at all
EXACT CHECK · IN CODE
decision matchespay / decline
clause id matches14.2(b)
cost per item$0.000
JUDGE · A MODEL CALL EACH
reads as a quoterubric, 4 lines
tone fits the letterrubric, 3 lines
cost per item$0.004
a judge is trusted only after it agrees with human labels

Where there is a right answer, score it in code. The decision matched or it did not. The cited clause number matched or it did not. Both checks run over 180 items in under a second and cost nothing. Where there is no answer key, against a written rubric. That costs one model call per item, and it has to be checked against human labels before anyone believes it. On these 180 items, two checks are exact and one is a judge.

THE FREE CHECK, AND THE ONE THAT COSTS A CALL
from langfuse import Langfuse

langfuse = Langfuse()
dataset = langfuse.get_dataset("claims-180")

def clause_matches(output: dict, expected: dict) -> int:
    # Exact check, written in code. All 180 items scored in under a second, for nothing.
    return int(output["clause"] == expected["clause"])

def quotes_rather_than_paraphrases(output: dict) -> int:
    # A judge. One model call per item, so 180 calls and about 40 seconds.
    verdict = judge_model.invoke(RUBRIC.format(answer=output["reason"]))
    return int(verdict.text().strip() == "QUOTES")

for item in dataset.items:
    output = decide(item.input)
    langfuse.create_score(name="clause", value=clause_matches(output, item.expected_output))
    langfuse.create_score(name="quotes", value=quotes_rather_than_paraphrases(output))
a judge costs a model call for every item scored, so the cheap checks go first and the judge only handles what has no right answer
STEP 03 / 04
SIX BETTER, NINE WORSE
“summarize” against “quote” · the same 180 claims
A · 41 wrong
B · 44 wrong
6 fixed9 brokennet −3 · CI blocks the merge

Run both prompt versions over the same 180 items and compare. Changing summarize to quote fixed 6 of the 9 paraphrase failures and broke 9 claims that had been correct, because the new word made the model cite a clause even when none applied. Net worse by 3, so it does not ship. The gate belongs in CI, failing the branch when the score drops, in the same place a failing test fails.

the one-word change fixed 6 claims and broke 9, and the only reason anyone knows is that both versions ran over the same 180 items
STEP 04 / 04
THE SAME TOOL, A DIFFERENT DAY
one store, one schema, two reasons to read it
production traces
dataset
experiment
CI gate
deploy
↳ the same scorer also runs on 2% of live trafficonline scoring
the only difference is whether the row arrived from a test or a user

This is not a separate vendor category, and the reason is structural: an eval needs stored inputs, stored outputs and somewhere to put scores, and a tracing product already has all three. Langfuse, LangSmith and Braintrust ship both halves. DeepEval and Ragas run as test libraries in CI with no platform behind them, and Ragas carries retrieval-specific metrics. Online scoring closes the loop by running the same scorer on a sample of live traffic. Layers: 5.

offline evals and production monitoring run the same scorer over the same schema, and the only difference is whether the row arrived from a test or a user
deep dive: the eval that is worth having

180 items is a small number. A two-item difference between versions is noise, and a fifteen-item difference is not. Running the same fixed set every time beats sampling fresh claims, because a moving set turns every comparison into an argument about which claims were harder.

The judge drifts. The grader is a model, the model gets upgraded, and last month’s scores stop being comparable. Pin the judge’s model version and record it beside the score, or accept that the eval history is a chart of two things changing at once.

The set grows from incidents. Every wrong claim someone complains about is one more row with a known answer. This is the only artifact in the tutorial that gets more valuable with age, and the only one worth backing up separately from the database it lives in.

Everything about rubrics and shipping decisions is in the evals tutorial, which follows one prompt change from complaint to deploy. The only question here is which layer owns the scoring.

07

The work that happens when nobody asks

Three things went wrong while nobody was looking. The policy team changed 312 pages yesterday and the index still holds the old text. The 180-item eval set has not run since Friday. Last night’s 4,000 traces are sitting in a table nobody has read. Every layer so far starts when a claim arrives, and none of these start at all. This one runs when the clock says so, and that difference is the whole reason a 2014 data-engineering tool is sitting in an AI stack.

STEP 01 / 05
A SCHEDULE IS NOT A GRAPH
both are graphs of steps · the difference is who starts them
PER REQUESTPER CLOCK TICK
what starts ita claim arrives02:00
what moves between stepstyped statestorage
how long it runsseconds, or 99 minutes41 minutes
can it wait for a personyesno
can you re-run last Tuesdaynoyes
how many at onceone per claimone per clock tick
LangGraph on the left · Airflow on the right · both in the same system

The tool for this job is a scheduler running a , which is a set of tasks and the dependencies between them, with no cycles, written as ordinary Python. LangGraph and Airflow are both directed graphs of steps, which is why people confuse them, and they answer different questions. A graph run starts because a claim arrived, carries typed state between nodes, and can pause for a person. A DAG run starts because a , a five-field expression naming the minutes and hours a job fires on, said 02:00. It passes data through storage rather than state, and it is expected to be repeatable for any past date.

both are graphs of steps, and the difference is who starts them: a request, or a clock
STEP 02 / 05
THE 02:00 DAG, FIVE TASKS
02:00, nobody asked · five tasks, in dependency order
pull_traces2 min
reembed_changed_pages31 min
rebuild_index3 min
run_eval_set4 min
post_scorecard1 min
elapsed00:00312 pages re-embedded · 180 items scored
two tasks retry on failure · one carries an SLA · every run is repeatable for a past date

The nightly run, five tasks. Pull yesterday’s 4,000 traces. Re-embed the 312 policy pages that changed and upsert them into the index. Rebuild what depends on them, run the 180-item eval set against the current prompt, and post a scorecard. 41 minutes end to end, of which re-embedding is 31. Two tasks retry on failure, one carries an SLA, and the whole run is repeatable for any past date. Re-embedding all 40,000 pages instead of the 312 that changed would take 66 hours, so a , meaning a re-run of the same job for a date in the past, is the feature here rather than the schedule.

THE RUN NOBODY ASKED FOR, AT 02:00
import pendulum
from airflow.sdk import dag, task

# The schedule is a clock, not a claim. catchup=False means backfill on purpose only.
@dag(schedule="0 2 * * *", catchup=False,
     start_date=pendulum.datetime(2026, 6, 1, tz="UTC"))
def claims_nightly():
    @task(retries=2)
    def reembed_changed_pages(data_interval_start=None) -> int:
        pages = policy_pages_changed_since(data_interval_start)   # 312 of 40,000
        store.add_documents(split(pages), ids=chunk_ids(pages))   # upsert by id, 31 minutes
        return len(pages)

    @task(retries=2)
    def run_eval(page_count: int) -> float:
        return score_dataset("claims-180")               # 4 min, then post the scorecard

    run_eval(reembed_changed_pages())                    # 41 min across all five tasks

claims_nightly()
re-embedding the 312 changed pages takes 31 minutes; re-embedding all 40,000 would take 66 hours
STEP 03 / 05
OCTOBER 2014, AT AIRBNB
the scheduler in your AI stack, and when it was written
started by Maxime Beauchemin · open source from the first commit

The scheduler doing that work is older than everything above it. Maxime Beauchemin started Airflow in October 2014 at Airbnb, open source from the first commit, announced in June 2015, an Apache incubator project in March 2016 and a top-level Apache project in January 2019. None of it was designed for models. The transformer paper came out two years and eight months after the first commit.

the scheduler now re-embedding policy pages for an AI system was started in October 2014, before the transformer paper existed
STEP 04 / 05
AIRFLOW 3 AND THE RULE THAT WAS DELETED
3.0 on 22 April 2025 · the largest release since 2020
AIRFLOW 2AIRFLOW 3
execution dateunique per runno longer required
DAG versionsthe latest file winsversioned per run
where a task runsthe scheduler’s boxanywhere, over an API
what triggers a runthe clockthe clock, or an event
the unit in the UIthe taskthe asset
3.3.0 shipped 6 July 2026 · the top row is the one that let inference jobs in

Airflow 3.0 became generally available on 22 April 2025, the largest release since 2.0 in 2020. It brought DAG versioning, a task execution API that lets a task run outside the scheduler’s environment and in another language, event-driven scheduling, and an asset-centric interface. The change that matters here is the removal of the rule that every run needs a unique execution date, which is what let inference and training jobs fit without workarounds. 3.3.0 shipped on 6 July 2026.

Airflow 3 deleted the requirement that every run has a unique date, and that one deletion is what let model inference jobs stop pretending to be daily reports
STEP 05 / 05
CLOCK, OR ENGINE
every column filled · and what else does each job
6schedules, backfills, batchAirflow
5datasets, judges, ship gatesLangfuse datasets
4traces, tokens, cost, latencyLangfuse
3a written agent loop, toolsClaude Agent SDK
2state, branches, pause, resumeLangGraph
1providers, typed output, RAGLangChain
0one HTTP request to a modelthe Anthropic SDK
a scheduler decides when work starts · a durable engine makes started work finish

Prefect and Dagster answer the same scheduled-batch question, Dagster by making the produced asset the unit rather than the task. Temporal answers a different question: a that owns one long-running execution, retries its activities, and takes a signal when a human replies, which is chapter 03’s problem and not this one. Plenty of platforms run a scheduler and a durable engine side by side, because the two jobs really are different. Layers: 6. Spans a night: 152,000.

a scheduler decides when work starts; a durable engine makes sure work that started finishes, and no single tool is good at both
deep dive: idempotence, backfills and the 02:00 failure

Every task here runs twice sooner or later. A retried embedding upsert has to overwrite by chunk id, not append, or the index grows duplicate chunks that outrank the original and change what the model reads. This is the same idempotent requirement as chapter 01’s retry, one layer up.

Backfilling 90 days is 90 runs. Repairing an index after a bad embedding model means re-running the DAG once per past date, not once with a date range, because each run’s output is scoped to its own day. That property is the reason the schedule and the run are separate concepts.

The failure that actually happens. The eval task passes while the embedding task produced an empty index, so the scorecard says everything is fine and the claims get worse in the morning. Freshness checks on the asset catch this; retries and alerts on the crash do not, because there was no crash.

The option nobody writes down. A cron entry and a shell script. It is the right answer until the first backfill, the first task that has to run somewhere else, or the first time someone asks which version of the index produced Tuesday’s decisions.

Σ

The six layers, and what each one owns

Claim 8842 arrived at 09:41:02 and was written back at 11:20:41, having crossed six layers, emitted 38 spans, spent 99 of those minutes as a row in a database, and cost 14 cents. Three boards follow. The first is the map: a question per row, with every product name beside it an answer to that one question. The second is the practical version, keyed on what is going wrong rather than on what the layer is called. The third lists every question this tutorial set out to answer, and where it was settled.

SIX LAYERS AROUND ONE MODEL CALL · CLAIM 8842 · 2026-07-28
LOWNSTHIS SYSTEMALSO DOES THIS JOB
6when work starts, and re-running itAirflowPrefect · Dagster
5is the new version betterLangfuse datasetsBraintrust · DeepEval · Ragas
4what happened, and what it costLangfuseLangSmith · Phoenix · Braintrust
3a loop of unknown lengthClaude Agent SDKOpenAI Agents SDK · Google ADK
2what runs next, and where state livesLangGraphTemporal · Pydantic AI · CrewAI
1providers, typed output, retrievalLangChainLlamaIndex · LiteLLM · Instructor
0one HTTP request to a modelthe Anthropic SDKany provider SDK
spans in claim 8842’s trace·······································38
tool calls the agent made·······································11
minutes the run spent paused for a human·······································99
compute time, excluding the pause·······································12.6 s
cost of this one claim·······································$0.14
claims a night, and the bill·······································4,000 · $560
lines of the original script still running·······································0
WHICH LAYER YOU ACTUALLY NEED, BY WHAT IS GOING WRONG
under 50 runs a day, all of them read·······································none of it. keep the script01
the corpus does not fit in one request·······································retrieval, layer 102
four vendors, one request to write·······································a provider abstraction, layer 102
a person has to approve some answers·······································a state graph, layer 203
the steps depend on what step 1 finds·······································a harness, layer 304
nobody can say what last night cost·······································a trace backend, layer 405
a prompt change may help or may hurt·······································a fixed eval set, layer 506
the index is stale by morning·······································a scheduler, layer 607
THE ELEVEN QUESTIONS, AND WHERE EACH ONE WAS ANSWERED
what is an AI stack·······································libraries wrapped around one HTTP request00
who maintains each layer·······································LangChain Inc., Anthropic, ClickHouse, Apache00
can I run all of it myself·······································yes. six pip installs and one API key00
when is none of it worth it·······································under ~50 runs a day with a person reading01
what picks the eight paragraphs·······································an embedding model and a vector index02
where does a paused run live·······································a checkpoint row, keyed by thread id03
who decides what runs next·······································the graph, until the length is unknown03, 04
what stops an agent doing damage·······································the permission list, not the prompt04
what did last night actually cost·······································a stored trace, priced span by span05
did the change help·······································both versions over one fixed 180-item set06
what runs when nobody asks·······································a cron DAG, and its backfills07
import anthropic · layer 0
from langchain.chat_models import init_chat_model · layer 1
from langgraph.graph import StateGraph · layer 2
from claude_agent_sdk import query · layer 3
from langfuse import observe · layers 4 and 5
from airflow.sdk import dag · layer 6
six imports · one claim · $0.14