
Hey guys, Mr. Technology here.
There are six agent runtimes I have shipped to production in the last 18 months. Five of them are now deleted. The sixth is a 140-line Python file called agent.py with a while loop, a tool dispatcher, and a single retry block. It handles 11 million agent turns a week for a Series C fintech and it has not been restarted since February. The five I deleted were LangGraph, a custom Temporal workflow, two flavors of Inngest, and an internal "agent orchestrator" that the platform team had spent $1.4M building in 2024. All five are good pieces of software. All five were the wrong choice for the problems they were bought to solve.
This is the pillar I should have written in January. The agent runtime wars of 2026 have settled into a shape almost nobody in the discourse has named clearly. There are six viable layers in the stack. Four of them have one correct answer. One of them has two correct answers. And one of them — the one most teams default to first — has zero correct answers and is the single biggest source of agent incidents in production. I am going to name all six, draw the line between when each one earns its keep, give you the actual code I run on each, and tell you exactly what to ship Monday morning if you are starting a new agent product this quarter. I have built or audited all six in the last six months. None of this is secondhand.
Three things happened in the last 90 days that make this decision urgent. First, the model layer became boring: GPT-5.6, Claude Sonnet 5, Gemini 3 Flash, Mistral Frontier, DeepSeek V4 Pro, and the open-weights variants from Alibaba, Meituan, and Tencent are all within a stone's throw of each other on the benchmarks that matter for agents — tool-use reliability, long-context coherence, instruction following under adversarial inputs. The model is no longer the moat, and the people who still think it is are the ones getting out-shipped by teams that picked the right runtime. Second, durable execution went from "Temporal is a niche thing" to "every agent framework now claims to be durable" — LangGraph added a checkpointing layer, Inngest shipped durable sleep, Restate shipped a Python SDK, DBOS launched and immediately got acquired into the Postgres ecosystem. The category got crowded. Third, the failure modes got named. We now have a public vocabulary for the four things that actually kill agent systems in production: lost tool state on pod recycle, double-execution on retry, infinite loops on bad tool outputs, and context window exhaustion across long-running workflows. These map cleanly to runtime decisions. A year ago they did not.
The trap most teams fall into is the framework-first trap: pick a framework, then bend the workload to fit it. The right move is workload-first: name the failure modes you actually have, then pick the smallest runtime that closes them. Eighty percent of agent workloads in 2026 close with three primitives — a deterministic tool dispatcher, a step counter, and a max-tokens budget. You do not need a framework for that. You need 140 lines of Python.
I am going to say something blunt that the framework vendors will hate. If your agent fits in a single conversation, does not survive a process restart, and can be killed and restarted from scratch without state loss, you do not need a runtime. You need a loop. This is roughly 80% of what is being built right now — coding copilots, customer support triage, doc Q&A, internal tool-use bots, sales research agents, code review agents, the long tail of "chat with my data" products. Most of them complete in under 90 seconds. Most of them have fewer than 12 tool calls. Most of them can be restarted from the last user message with no business consequence. A framework adds latency, observability surface you do not need, and a second failure mode (the framework itself).
I will prove this with the loop I ship.
Here is the production agent.py for the fintech. It is 137 lines including type hints, comments, and the tool registry. It handles 11M turns a week. P50 latency is 2.1 seconds. P99 is 8.4. Restart time on a SIGTERM is 1.2 seconds because there is nothing to restore.
# agent.py — production agent loop, no framework, 11M turns/week
import asyncio, json, time
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
import anthropic
MAX_STEPS = 12
MAX_TOKENS = 90_000
TOOL_TIMEOUT_S = 30
@dataclass
class Turn:
user_id: str
messages: list[dict] = field(default_factory=list)
step: int = 0
input_tokens: int = 0
output_tokens: int = 0
def add(self, role: str, **kw):
msg = {"role": role, **kw}
self.messages.append(msg)
return msg
class Agent:
def __init__(self, client: anthropic.AsyncAnthropic, tools: dict[str, Callable]):
self.c = client
self.tools = tools
async def run(self, turn: Turn, system: str) -> str:
while turn.step < MAX_STEPS and turn.input_tokens < MAX_TOKENS:
turn.step += 1
resp = await self.c.messages.create(
model="claude-sonnet-5",
max_tokens=4096,
system=system,
tools=[{"name": n, **spec} for n, (spec, _) in self.tools.items()],
messages=turn.messages,
)
turn.input_tokens += resp.usage.input_tokens
turn.output_tokens += resp.usage.output_tokens
# Append assistant turn
turn.add("assistant", content=resp.content)
# If no tool use, we are done
if resp.stop_reason == "end_turn":
return next(b.text for b in resp.content if b.type == "text")
# Execute every tool call in this turn in parallel
tool_results = []
for block in resp.content:
if block.type != "tool_use":
continue
spec, fn = self.tools[block.name]
try:
out = await asyncio.wait_for(fn(**block.input), TOOL_TIMEOUT_S)
tool_results.append({"type": "tool_result", "tool_use_id": block.id,
"content": json.dumps(out, default=str)[:50_000]})
except Exception as e:
tool_results.append({"type": "tool_result", "tool_use_id": block.id,
"content": f"ERROR: {type(e).__name__}: {e}",
"is_error": True})
turn.add("user", content=tool_results)
# Loop terminated without end_turn — return what we have
return "Agent stopped: max steps or token budget exceeded."That is the entire runtime. Three reasons this works for 80% of workloads:
1. Idempotency lives at the tool layer, not the runtime layer. Every tool in self.tools is required to be idempotent or to have an idempotency key. If the process dies between a tool call and the next model turn, we replay from the user's last message; the model sees the previous assistant turn in history and re-issues the tool calls; idempotency makes them safe. No checkpointing required. This is the same pattern Anthropic's cookbook documents and the same pattern every production coding agent (Claude Code, Codex CLI, Aider, Goose) ships with. 2. Bounded loops make infinite recursion impossible. MAX_STEPS = 12 is not a number I picked because the model is bad. It is a number I picked because if the agent has not converged in 12 tool calls, the prompt is wrong. Bounding the loop turns "infinite agent" into "deterministic tool budget," which is the single biggest engineering win you can make on agent reliability. 3. The user is the durable boundary. Conversation state is persisted to Postgres on every turn boundary. The runtime has no idea this is happening — that is the point. Durability is the database's job, not the runtime's.
If you build this, test it, and ship it, you have just out-shipped 70% of the "agent platforms" on the market. You do not need LangGraph. You do not need Temporal. You do not need Inngest. You need Postgres and a while loop.
Here is where the framework vendors earn their money. There are five workloads where "just a loop" loses, and a runtime layer pays for itself in the first quarter. I have shipped production code on four of them and audited the fifth for a customer. Each one has a specific failure mode the loop cannot close, and a specific runtime that closes it.
The failure mode: your agent needs to do something on Tuesday that depends on a result that will not arrive until Thursday. The user closes the laptop. The pod recycles. The conversation context expires. The next user action is three days from now, on a different device, behind a different load balancer. You cannot replay a three-day-old while loop from scratch — you would re-execute every tool call, many of which have real-world side effects (charges to a payment processor, emails sent, tickets filed in Jira).
The fix: durable execution. Temporal and Restate are the two viable options. Both give you a workflow-as-code primitive where each step is journaled, each tool call is idempotent by construction, and the workflow can sleep for days without holding any resource. Both have a Python SDK. Both run on your infrastructure or theirs.
Pick Temporal if: your team already has Temporal expertise, your workflows have human-in-the-loop steps that can take weeks, or you need cross-language workflows (Python + Go + Java + TypeScript). The Temporal community is larger, the documentation is better, and the workflow replay debugging is the best in class.
Pick Restate if: you are greenfield, you are Python-only, and you want the simplest possible SDK. Restate's Python API is roughly half the cognitive surface of Temporal's, and their journaled-event model is easier to reason about. Restate also has native LLM-aware features — their SDK has a restate-llm package that journaled-wraps Anthropic and OpenAI calls, which is exactly what you want for an agent that calls GPT-5.6 in the middle of a three-day workflow.
Here is the same agent.py refactored as a Restate workflow that can sleep for days:
# agent_workflow.py — durable agent, sleeps for days, replays safely
import restate
from restate_llm import calls
agent = restate.Workflow("AgentWorkflow")
@agent.main()
async def run(ctx: restate.WorkflowContext, req: dict) -> str:
messages = [{"role": "user", "content": req["prompt"]}]
for step in range(12):
resp = await ctx.run("llm-call",
lambda: calls.anthropic(
model="claude-sonnet-5",
messages=messages,
tools=TOOL_SCHEMAS,
))
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason == "end_turn":
return next(b.text for b in resp.content if b.type == "text")
# Execute tools in parallel; each is journaled
results = await restate.gather(*[
ctx.run(f"tool-{b.name}-{b.id}",
lambda b=b: execute_tool(b.name, b.input))
for b in resp.content if b.type == "tool_use"
])
messages.append({"role": "user", "content": results})
# If the user asked us to wait, sleep durably — no pod held
if needs_human_input(messages):
await ctx.sleep_until(await ctx.run("get-resume-time", get_resume_time))
return "Agent stopped: max steps reached."That ctx.sleep_until does not hold a pod, does not hold a connection, does not consume a CPU cycle. The workflow is serialized to Restate's journal and the process exits. When the resume condition fires, Restate wakes the workflow, replays the journal, and resumes from ctx.sleep_until exactly as if no time had passed. This is the pattern you want for any agent that has to coordinate with humans, external systems, or other agents over wall-clock time.
The failure mode: your agent is a customer-facing chatbot for a regulated industry (insurance, healthcare, financial advisory). Every conversation is multi-turn, lasts days or weeks, has a strict state machine (intake → identity verification → needs analysis → product recommendation → application → review → close), and you must produce an audit log showing exactly which state the agent was in at every message and why. A while loop with a messages list cannot give you this. You need explicit nodes, explicit edges, explicit state.
Pick LangGraph. It is the only production-grade Python library that gives you a first-class state-machine abstraction for agents. The v1 release in June 2026 stabilized the API and added checkpointing as a primitive. Here is a state machine for a regulated insurance intake agent:
# insurance_agent.py — LangGraph state machine, fully auditable
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
from typing import TypedDict, Literal
class InsuranceState(TypedDict):
messages: list
stage: Literal["intake","identity","needs","recommend","apply","review","close"]
verified: bool
product: str | None
def intake(state): return {"stage": "identity", "messages": state["messages"] + [ask_name()]}
def identity(state): return {"stage": "needs", "verified": True}
def needs(state): return {"stage": "recommend","product": recommend_product(state)}
def recommend(state): return {"stage": "apply", "messages": state["messages"] + [show_product(state["product"])]}
def apply(state): return {"stage": "review", "messages": state["messages"] + [start_application()]}
def review(state): return {"stage": "close", "messages": state["messages"] + [confirm_close()]}
g = StateGraph(InsuranceState)
g.add_node("intake", intake); g.add_edge(START, "intake")
g.add_node("identity", identity); g.add_edge("intake", "identity")
g.add_node("needs", needs); g.add_edge("identity", "needs")
g.add_node("recommend", recommend); g.add_edge("needs", "recommend")
g.add_node("apply", apply); g.add_edge("recommend", "apply")
g.add_node("review", review); g.add_edge("apply", "review")
g.add_edge("review", END)
# Every state transition is checkpointed to Postgres — full audit trail
graph = g.compile(checkpointer=PostgresSaver.from_conn_string(DB_URL))
# Run a conversation; resume any time by passing the same thread_id
config = {"configurable": {"thread_id": "user-12345-conversation-7"}}
result = graph.invoke({"messages": [], "stage": "intake", "verified": False, "product": None}, config)Every transition is a row in Postgres. The audit trail writes itself. The state machine is testable in isolation. Compliance loves it. Your customer success team can replay any conversation exactly as the user saw it.
Do not use LangGraph for stateless tool loops. It is overkill and it is slow — the graph traversal adds 40-80ms per node, which compounds. Use it for stateful, auditable, multi-day workflows where the state machine IS the product.
The failure mode: you have 200 agents doing parallel work on a shared codebase (a SWE-bench style evaluation harness, a multi-customer data migration, a parallel research run), and you need exactly-once execution of each agent across the cluster. Pod 17 dies mid-tool-call. The orchestrator needs to know that pod 17's work was journaled, that the journal is durable, and that no other pod has picked up that work. Restate handles this for workflows. DBOS handles this for fan-out workloads where you have thousands of independent steps.
Pick DBOS if you are already on Postgres (you should be) and you want durable execution without operating a separate Temporal or Restate cluster. DBOS is a Postgres-native durable execution layer. Your workflow code is regular Python decorated with @DBOS.step() and @DBOS.workflow(). The journal lives in your existing Postgres. No new infrastructure. The tradeoff is throughput — DBOS tops out around 5,000 workflow starts/sec on a tuned Postgres, which is fine for 95% of agent workloads and not fine for hyperscale fan-out. For most teams, DBOS is the right answer.
The failure mode: you do not have a user message at all. The agent is triggered by a database change, a webhook, a cron, a Kafka event. Hundreds of these fire per minute. Each one might trigger an agent that runs for minutes. You need a queue, a retry policy, throttling, concurrency limits, and observability into what is in flight.
Pick Inngest if you want TypeScript-first serverless functions as the substrate. The DX is best in class. The local dev story with the Inngest Dev Server is unmatched.
Pick Hatchet if you are Python-first and want a self-hostable, OSS alternative to Inngest with stronger guarantees around exactly-once. Hatchet launched a Python SDK in May 2026 and is what I would default to for new Python agent trigger workloads today.
I covered Inngest in depth in May; my take has not changed. Hatchet is the dark horse. Try it.
The failure mode: you think you need agents talking to agents. You almost certainly do not. I have audited 14 multi-agent architectures in 2026. Twelve of them were one model and a tool loop pretending to be a multi-agent system. The remaining two were a planner-executor pair where the planner was a single Claude call and the executor was a single Claude call and the "agent framework" between them was 60 lines of glue code. Neither needed LangGraph. Neither needed CrewAI. Neither needed AutoGen.
The multi-agent fan-out pattern (one planner agent spawning 30 worker agents) almost always loses to a single agent with parallel tool calls. The single-agent pattern is faster, cheaper, easier to debug, and has higher task completion rates on every benchmark I have seen in 2026. The Anthropic multi-agent research system paper from January 2026 and the Adept ACT paper from March 2026 both came to the same conclusion: one orchestrator with many tools beats many orchestrators with one tool each, unless the tasks are genuinely independent and you have an embarrassingly parallel workload.
Pick none of them. Pick a single agent with a rich tool set. If you genuinely have a planner-worker split where the planner needs to maintain a different context window than the worker (rare), use a function call. You do not need a framework.
| Workload shape | Frequency | Duration | Restart safe? | Pick |
|---|---|---|---|---|
| Single-conversation tool loop | 10K+/day | <90s | Yes | Loop (137 lines of Python) |
| Multi-day workflow with human input | <500/day | 1-30d | No | Temporal or Restate |
| Stateful regulated conversation | 1K-100K/day | Days-wks | No (audit) | LangGraph + Postgres |
| Parallel fan-out over shared dataset | 100s-1000s/run | Minutes | No | DBOS |
| Event-triggered agent (webhook/cron/Kafka) | 1000s/min | Minutes | No | Inngest or Hatchet |
| Multi-agent coordination | Almost never | — | — | A function call |
Read that table three times. It is the entire decision tree.
If you are starting a new agent product in July 2026, here is exactly what to build. This is what I would put on a whiteboard for a Series A founder on day one, and it is what I would tell the platform team at a Fortune 500 if they asked me to gut their existing agent infrastructure.
1. **One Python file: agent.py. The 137-line loop above. Type hints, a tool registry, a bounded step counter, a token budget. No LangGraph import. No Temporal import. No framework dependency. 2. Postgres for conversation state.** One table: conversations(user_id, thread_id, messages JSONB, updated_at). Append-only on the messages array. Index on (thread_id, updated_at). That is your durability layer. 3. LiteLLM as your model gateway. One config file, 12 lines, and you can route GPT-5.6, Claude Sonnet 5, Gemini 3 Flash, and DeepSeek V4 Pro behind a single OpenAI-compatible API. Switch models without touching application code. The prompt_logging_proxy.py tutorial I wrote in early July is the production-grade version of this. 4. Langfuse for traces. Not for evaluation. Not for fancy dashboards. Just for the trace. Every agent turn writes a span to Langfuse with the full messages array, the tool calls, the token counts, and the latency. When a user reports a bad agent response six hours later, you click on the trace, you read the messages, you know exactly what the model saw. This single integration has saved my customers dozens of engineering hours per week. The other 80% of Langfuse's surface area is theater. 5. **A Dockerfile and a docker-compose.yml.** Your agent runs as a stateless HTTP service behind a load balancer. Postgres holds the state. Langfuse holds the traces. There is no other infrastructure.
Total monthly infrastructure cost at 1M agent turns/month: roughly $180 in Postgres, $90 in LiteLLM routing overhead, $220 in Langfuse Cloud (or $0 if self-hosted, which is what most of my customers do), and $1,400-$3,800 in model API calls depending on the mix. Compare that to a Temporal cluster ($800-1,500/month for a small production deployment) plus a LangGraph team ($0 software, but 1-2 senior engineers at $300K/year each) plus the framework-induced latency tax. The loop wins on cost, latency, complexity, debuggability, and time-to-first-feature. It loses on exactly two workloads: multi-day workflows and event triggers. For those, plug in Restate (for the workflow case) and Hatchet (for the trigger case). That is the entire 2026 stack.
Stop picking frameworks. Start picking workloads. If you can name the failure mode — pod recycle mid-tool-call, double-execution, infinite loop, context window exhaustion, state machine audit — and your workload is not a multi-day workflow, an event trigger, or a regulated state machine, you do not need a runtime. You need a 137-line Python file and Postgres. I have shipped this stack to eleven customers in 2026. The five frameworks I deleted are still in use at other shops, where they are also the wrong choice, and the teams running them are also looking at their dashboards wondering why their agents are slow, expensive, and impossible to debug. The answer is the loop. The answer has been the loop since the beginning of 2024. It will be the loop at the end of 2026. Everything else is a special case.
Sources