Everything up to here was about getting a graph right. This section is about keeping one running: awaiting things without blocking, routing at runtime, stopping for a human, surviving a process restart, putting the whole thing behind HTTP, and reading what happened afterwards.
Every Python snippet below was executed exactly as printed, against
grapharc.testing.ScriptedChatModel, and tests/test_cookbook_serving.py
re-runs each one and byte-compares it against the output block underneath. No
live model is called anywhere in this file; the one snippet that would need a
key says so and was not run.
The console transcripts are held to the same standard, marked the way
02-models.md marks its own. <!-- verified: cli --> above a transcript means
every command in it is re-run on every commit and byte-compared — run ids are
random and durations are wall-clock, so the test maps the former and masks the
latter, and every other character has to match. <!-- verified: cli varies -->
means every command is re-run and must succeed, but the output describes one
machine — serve binds a port, models --check probes the host — so its bytes
are the record of one real run rather than a guarantee about yours. The one
agent invocation always needs a live model; it is marked
<!-- needs-credentials --> and nothing here claims to have run it.
Three things to know before you start:
- Async and sync are the same contract. Write permissions, type checks,
budgets and traces are one implementation. What changes is which entry point
you use and how
max_secondsis delivered. - Sessions are the durable layer. A
CompiledGraphARCplus a checkpointer gets you a resumable thread. ASessionadds the things a thread has no opinion about: who is driving it, what was queued for it, whether a human still has to sign something off. - The trace file is the only record.
replay,diff,cost,metricsandvizall read the same JSONL. There is no second source of truth to disagree with it.
Write the node async def and drive the graph with ainvoke(). Nothing else
changes — the node still returns a dict, still declares its writes, still gets
a deep-copied state.
import asyncio
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
url: str
body: str = ""
async def fetch(state: State) -> dict:
await asyncio.sleep(0.01) # a real client would await here
return {"body": f"<html>{state.url}</html>"}
g = GraphARC(State, name="fetcher")
g.add_node("fetch", fetch, writes={"body"})
g.add_edge(START, "fetch")
g.add_edge("fetch", END)
graph = g.compile()
result = asyncio.run(graph.ainvoke({"url": "https://example.com"}))
print(result["body"])<html>https://example.com</html>
Sync nodes stay legal on the async path — LangGraph puts them on its own worker
threads — so a graph may mix the two freely as long as you drive it with
ainvoke() / astream().
Why it works this way. max_seconds is the one thing that genuinely
differs. On a sync node the ceiling is delivered as an interrupt to the thread
running it; on an async def node it is delivered as task cancellation,
because a signal aimed at "the thread" on an event loop would land in whichever
coroutine happened to be running. The practical consequence is stated in
grapharc.runtime.graph._async_deadline: cancellation arrives at an await, so
an async node doing blocking CPU work is not interrupted until it yields.
Because it found an async def node and there is no event loop to await it on.
The check happens at the entry point, before the first node.
import asyncio
from grapharc.runtime.graph import END, START, AsyncNodeError, GraphARC
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
log: list[str] = []
def cheap(state: State) -> dict:
print("cheap ran")
return {"log": [*state.log, "cheap"]}
async def costly(state: State) -> dict:
await asyncio.sleep(0)
return {"log": [*state.log, "costly"]}
g = GraphARC(State, name="mixed")
g.add_node("cheap", cheap, writes={"log"})
g.add_node("costly", costly, writes={"log"})
g.add_edge(START, "cheap")
g.add_edge("cheap", "costly")
g.add_edge("costly", END)
graph = g.compile()
try:
graph.invoke({})
except AsyncNodeError as exc:
print(f"AsyncNodeError: {exc}")
print(asyncio.run(graph.ainvoke({}))["log"])AsyncNodeError: graph 'mixed' has async nodes ['costly']; use ainvoke(), because invoke() has no event loop to await them on
cheap ran
['cheap', 'costly']
Read the output order carefully: cheap ran prints once, from the
ainvoke() call. The refused invoke() did not execute it. That is the whole
point of a pre-flight check — LangGraph on its own would have run every sync
node first and then died on the first coroutine with a TypeError, leaving you
with half a run and a confusing traceback.
stream() refuses the same way and names astream() in its message.
astream(). It takes the same stream_mode values LangGraph does; "updates"
gives you one chunk per node with just what that node wrote.
import asyncio
from langchain_core.messages import HumanMessage
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
draft: str = ""
final: str = ""
model = ScriptedChatModel(
responses=["budgets cap iterations", "budgets cap iterations, tokens and time"]
)
async def draft(state: State) -> dict:
reply = await model.ainvoke([HumanMessage(content=state.question)])
return {"draft": str(reply.content)}
async def polish(state: State) -> dict:
reply = await model.ainvoke([HumanMessage(content=f"polish: {state.draft}")])
return {"final": str(reply.content)}
g = GraphARC(State, name="writer")
g.add_node("draft", draft, writes={"draft"})
g.add_node("polish", polish, writes={"final"})
g.add_edge(START, "draft")
g.add_edge("draft", "polish")
g.add_edge("polish", END)
graph = g.compile()
async def main() -> None:
async for chunk in graph.astream(
{"question": "how do budgets work?"}, stream_mode="updates"
):
for node, delta in chunk.items():
print(node, "->", delta)
asyncio.run(main())draft -> {'draft': 'budgets cap iterations'}
polish -> {'final': 'budgets cap iterations, tokens and time'}
A "updates" chunk is a {node_name: delta} dict, not a (name, delta) pair —
unpacking it as two values raises ValueError: not enough values to unpack.
Pass a list of modes (stream_mode=["updates", "checkpoints"]) and the chunks
become (mode, payload) tuples instead. That second shape is what the session
runtime uses, because checkpoints is the only chunk that means "the superstep
is finished and on disk".
astream_events() is LangChain's event stream, driven through GraphARC's
budgeted path. It emits per-node and per-model-call events, so it is what you
want behind a "thinking…" UI or a token meter.
import asyncio
from langchain_core.messages import HumanMessage
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
answer: str = ""
model = ScriptedChatModel(responses=["42"])
async def answer(state: State) -> dict:
reply = await model.ainvoke([HumanMessage(content=state.question)])
return {"answer": str(reply.content)}
g = GraphARC(State, name="qa")
g.add_node("answer", answer, writes={"answer"})
g.add_edge(START, "answer")
g.add_edge("answer", END)
graph = g.compile()
async def main() -> None:
async for event in graph.astream_events({"question": "meaning of life?"}):
if event["event"] == "on_chain_start":
print("start", event["name"])
elif event["event"] == "on_chat_model_end":
usage = event["data"]["output"].usage_metadata
print("model", event["name"], usage["total_tokens"], "tokens")
asyncio.run(main())start LangGraph
start answer
model ScriptedChatModel 5 tokens
version defaults to "v2" and "v1" is accepted. "v3" is refused with a
ValueError rather than passed through, because LangGraph returns a stream
object for v3, not an async iterator — a different shape than this method's
contract.
You do not have to. CompiledGraphARC.inner is the LangGraph object underneath
and is reachable, and driving it directly fails closed at the first node.
import asyncio
from grapharc.runtime.graph import END, START, GraphARC, MissingRunContextError
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
x: int = 0
def bump(state: State) -> dict:
return {"x": state.x + 1}
g = GraphARC(State, name="counter")
g.add_node("bump", bump, writes={"x"})
g.add_edge(START, "bump")
g.add_edge("bump", END)
graph = g.compile()
# The LangGraph object underneath is reachable — and refuses to run unbudgeted.
try:
graph.inner.invoke({"x": 0})
except MissingRunContextError as exc:
print("MissingRunContextError:", exc)
async def v3() -> None:
async for _ in graph.astream_events({"x": 0}, version="v3"):
pass
try:
asyncio.run(v3())
except ValueError as exc:
print("ValueError:", exc)MissingRunContextError: node 'bump' executed without a GraphARC run context; drive the graph via CompiledGraphARC.invoke()/stream()/ainvoke()/astream() — raw LangGraph entry points would silently bypass budgets and traces
ValueError: astream_events supports version 'v1' or 'v2', got 'v3'
Return a langgraph.types.Command (re-exported from grapharc.runtime.graph).
Its update goes through exactly the checks a returned dict does, so dynamic
routing costs nothing in discipline.
from grapharc.runtime.graph import END, START, Command, GraphARC
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
text: str
verdict: str = ""
outcome: str = ""
def triage(state: State) -> Command:
if "urgent" in state.text:
return Command(update={"verdict": "urgent"}, goto="escalate")
return Command(update={"verdict": "routine"}, goto="archive")
def escalate(state: State) -> dict:
return {"outcome": "paged the on-call"}
def archive(state: State) -> dict:
return {"outcome": "filed"}
g = GraphARC(State, name="triage")
g.add_node("triage", triage, writes={"verdict"})
g.add_node("escalate", escalate, writes={"outcome"})
g.add_node("archive", archive, writes={"outcome"})
g.add_edge(START, "triage")
g.add_edge("escalate", END)
g.add_edge("archive", END)
graph = g.compile()
for text in ("urgent: disk full", "weekly digest"):
result = graph.invoke({"text": text})
print(f"{text!r} -> {result['verdict']}, {result['outcome']}")'urgent: disk full' -> urgent, paged the on-call
'weekly digest' -> routine, filed
Note there is no add_edge(START, ...) for escalate or archive and no
conditional edge at all: the goto is the transition. The nodes still need
their outgoing edges to END.
Command(update=...) must be a dict, and Command(graph=...) is refused —
an update aimed at another graph's state cannot be checked against this
graph's declared writes.
It raises. This is the case worth knowing about, because plain LangGraph does
not raise: it logs wrote to unknown channel branch:to:<x>, ignoring it and
carries on as if the node had never routed.
from grapharc.runtime.graph import END, START, Command, GraphARC, GraphRoutingError
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
text: str
outcome: str = ""
def triage(state: State) -> Command:
return Command(goto="esclate") # typo
def escalate(state: State) -> dict:
return {"outcome": "paged the on-call"}
g = GraphARC(State, name="triage")
g.add_node("triage", triage, writes={"outcome"})
g.add_node("escalate", escalate, writes={"outcome"})
g.add_edge(START, "triage")
g.add_edge("escalate", END)
graph = g.compile()
try:
graph.invoke({"text": "urgent: disk full"})
except GraphRoutingError as exc:
print(f"GraphRoutingError: {exc}")GraphRoutingError: node 'triage' routed to 'esclate', which is not a node of graph 'triage'; LangGraph drops an unknown destination and the run continues as if the routing had not happened. Valid destinations: 'escalate', 'triage', END
Why it works this way. A transition that silently does not happen is the
same defect as one that happens unpermitted: the graph did something other than
what the node asked for and nobody was told. The check covers node names, END,
Send targets from an add_fanout_edge dispatcher, and sequences of those. It
also covers Command(goto=None), which LangGraph cannot iterate at all.
The stated limit: this covers destinations GraphARC hands to LangGraph. A
Command passed as input to invoke()/stream() is outside it — those entry
points take a dict, a state model or None, and a Command there is
unsupported.
Compile with a checkpointer, stream with interrupt_before, then use
get_state / update_state / resume-with-None.
from langgraph.checkpoint.memory import InMemorySaver
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
topic: str
draft: str = ""
sent: str = ""
def write(state: State) -> dict:
return {"draft": f"Dear customer, about {state.topic}..."}
def send(state: State) -> dict:
return {"sent": state.draft}
g = GraphARC(State, name="mailer")
g.add_node("write", write, writes={"draft"})
g.add_node("send", send, writes={"sent"})
g.add_edge(START, "write")
g.add_edge("write", "send")
g.add_edge("send", END)
graph = g.compile(checkpointer=InMemorySaver())
# Run until `send` is next, then stop.
for _ in graph.stream({"topic": "the outage"}, thread_id="t1", interrupt_before=["send"]):
pass
snapshot = graph.get_state("t1")
print("next :", snapshot.next)
print("draft :", snapshot.values["draft"])
# A human edits the draft, attributed to the node that produced it.
edited = "Dear customer, we are sorry about the outage."
graph.update_state("t1", {"draft": edited}, as_node="write")
# Resume: input=None picks up from the checkpoint.
result = graph.invoke(None, thread_id="t1")
print("sent :", result["sent"])next : ('send',)
draft : Dear customer, about the outage...
sent : Dear customer, we are sorry about the outage.
Sharp edge: interrupt_before is a stream keyword, not an invoke
keyword and not a compile keyword. invoke(...) takes only
input, thread_id, run_id and budget; passing interrupt_before= to it
raises TypeError. Run the interrupted leg with stream() (draining the
iterator, as above) and resume with whichever you prefer.
get_state and friends need a checkpointer at compile time; without one
LangGraph raises ValueError("No checkpointer set"). They read and write
checkpoints rather than executing nodes, so they carry no budget and emit no
trace events.
Field names and declared types always; the node's write allowlist only when you say which node the edit is attributed to.
from langgraph.checkpoint.memory import InMemorySaver
from grapharc.runtime.graph import END, START, GraphARC, StateTypeError, WritePermissionError
from grapharc.runtime.state import GraphARCState
class State(GraphARCState):
topic: str
draft: str = ""
sent: str = ""
def write(state: State) -> dict:
return {"draft": f"Dear customer, about {state.topic}..."}
def send(state: State) -> dict:
return {"sent": state.draft}
g = GraphARC(State, name="mailer")
g.add_node("write", write, writes={"draft"})
g.add_node("send", send, writes={"sent"})
g.add_edge(START, "write")
g.add_edge("write", "send")
g.add_edge("send", END)
graph = g.compile(checkpointer=InMemorySaver())
for _ in graph.stream({"topic": "the outage"}, thread_id="t1", interrupt_before=["send"]):
pass
# 1. Unknown field: refused whether or not you claim a node.
try:
graph.update_state("t1", {"draftt": "oops"})
except WritePermissionError as exc:
print("1:", exc)
# 2. Wrong type: refused.
try:
graph.update_state("t1", {"draft": 42})
except StateTypeError as exc:
print("2:", exc)
# 3. Claiming a node you are not allowed to write as: refused.
try:
graph.update_state("t1", {"sent": "forged"}, as_node="write")
except WritePermissionError as exc:
print("3:", exc)
# 4. No as_node: type-checked only, no allowlist to apply.
graph.update_state("t1", {"sent": "written from outside any node"})
print("4:", graph.get_state("t1").values["sent"])1: update_state targets unknown state fields: ['draftt']
2: update_state wrote 'draft' with a value the state schema rejects: expected str, got int (42); Input should be a valid string
3: update_state(as_node='write') wrote undeclared fields ['sent']; declared writes: ['draft']
4: written from outside any node
Why case 4 is not an allowlist violation. A human editing state mid-run is
not a node, so there is no allowlist to apply. as_node="write" says "record
this as if write had done it", and then write's declared writes are exactly
the right contract. The residual gap, stated rather than papered over: with
as_node=None LangGraph attributes the update to whichever node last ran, and
GraphARC does not reproduce that inference — so such an update is type-checked
but not allowlisted. If you want the allowlist, name the node.
Use a Session. The graph's state schema derives from SessionState, the
gated nodes are named on the GraphSpec, and the runtime holds the graph
before the gated node runs.
grapharc.session.demo ships a four-node graph (ingest -> plan -> apply -> report) with apply gated, so you can see the shape before writing your own.
Importing the module is what registers the graph.
import tempfile
from grapharc.session import SessionManager
from grapharc.session.demo import GRAPH_NAME # importing registers the graph
root = tempfile.mkdtemp(prefix="cookbook-")
with SessionManager(root) as manager:
session = manager.create(GRAPH_NAME)
session.send("summarise the incident")
first = session.run({})
print("status :", first.status.value)
print("ran :", first.nodes)
print("holding :", [(h.node, h.action) for h in first.approvals])
session.decide(approved=True, decided_by="ops")
second = session.run()
print("status :", second.status.value)
print("ran :", second.nodes)
print("outcome :", second.state["outcome"])
print("log :", second.state["log"])status : awaiting_approval
ran : ('ingest', 'plan')
holding : [('apply', 'apply: draft the release note')]
status : idle
ran : ('apply', 'report')
outcome : applied: draft the release note
log : ['ingest', 'plan', 'apply', 'report']
TurnResult.nodes is the honest answer to "what executed". apply is absent
from the first turn's list because its body never ran.
Why it works this way. SessionManager(root) puts a session store and a
checkpoint store in one directory, so "resume this session elsewhere" is "point
another SessionManager at the same directory". run() is synchronous and
occupies its caller until the session stops — the kernel grew astream while
the session layer was being written and an async turn is buildable, just not
built.
The gate belongs to the session, not to the graph. session.graph.invoke(...)
runs gated nodes with nothing holding them. If that matters to you, do not hand
the compiled graph to anything that will not go through a session.
decide(approved=False). Nothing the gated node would have done has happened
yet, so a rejection costs nothing to honour — the graph walks past the node
rather than running it and undoing it.
import tempfile
from grapharc.session import ApprovalRequired, SessionManager
from grapharc.session.demo import GRAPH_NAME
with SessionManager(tempfile.mkdtemp(prefix="cookbook-")) as manager:
session = manager.create(GRAPH_NAME, session_id="incident-42")
session.run({})
# Running while a hold is open is refused before any work happens.
try:
session.run()
except ApprovalRequired as exc:
print("refused :", exc)
session.decide(approved=False, decided_by="ops", reason="wrong quarter")
turn = session.run()
print("status :", turn.status.value)
print("ran :", turn.nodes)
print("skipped :", turn.skipped)
print("log :", turn.state["log"])
print("outcome :", turn.state["outcome"])refused : session 'incident-42' is holding node 'apply' awaiting approval '2f29726ed94f'; call decide() before run()
status : idle
ran : ('report',)
skipped : ('apply',)
log : ['ingest', 'plan', 'report']
outcome : refused by ops: wrong quarter
(The approval request id is a fresh random value per hold; yours will differ.)
log is append-only in the demo graph, and 'apply' is not in it — the node's
body never executed. What did run is the node's outgoing edges, filed against
its own pending task, which is what advances the graph. report then reads
state.decision and routes on the verdict.
The ApprovalRequired refusal happens before the session claims anything, so a
refused run() leaves the queue, the holds and the checkpoint exactly as it
found them.
SessionManager.resume(session_id) in the new process. Nothing about a session
lives in memory: status, queue, holds and audit trail are rows in SQLite, graph
state is in the checkpointer, and the graph itself is rebuilt by name from the
registry.
This snippet spawns two genuinely separate interpreters to prove it.
"""Prove a session survives a process restart: two child interpreters, one directory."""
import json
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(tempfile.mkdtemp(prefix="cookbook-"))
FIRST = """
import json, os, sys
from grapharc.session import SessionManager
from grapharc.session.demo import GRAPH_NAME # importing registers the graph
with SessionManager(sys.argv[1]) as manager:
session = manager.create(GRAPH_NAME, session_id="incident-42")
session.send("summarise the incident")
turn = session.run({})
print(json.dumps({"pid": os.getpid(), "status": turn.status.value,
"ran": turn.nodes, "log": turn.state["log"]}))
"""
SECOND = """
import json, os, sys
from grapharc.session import SessionManager
from grapharc.session.demo import GRAPH_NAME
with SessionManager(sys.argv[1]) as manager:
session = manager.resume("incident-42") # rebuilt from the store
session.decide(approved=True, decided_by="ops")
turn = session.run()
print(json.dumps({"pid": os.getpid(), "status": turn.status.value,
"ran": turn.nodes, "log": turn.state["log"]}))
"""
def child(script: str) -> dict:
path = ROOT / "child.py"
path.write_text(script, encoding="utf-8")
out = subprocess.run(
[sys.executable, str(path), str(ROOT)], capture_output=True, text=True, check=True
)
return json.loads(out.stdout)
a, b = child(FIRST), child(SECOND)
print("process A", a["pid"], a["status"], a["ran"], a["log"])
print("process B", b["pid"], b["status"], b["ran"], b["log"])
print("same process?", a["pid"] == b["pid"])process A 479013 awaiting_approval ['ingest', 'plan'] ['ingest', 'plan']
process B 479020 idle ['apply', 'report'] ['ingest', 'plan', 'apply', 'report']
same process? False
(The pids are whatever the OS handed out; yours will differ.)
log is the proof, not the status. The demo graph appends its own node name on
every execution, so a resume that quietly re-ran ingest and plan would show
them twice. It shows each once.
Sharp edge: the resuming process must be able to build the graph.
register_graph is per-process and a session record only stores a name, so a
resume in a process that never imported the registering module fails with
UnknownGraphError — loudly, which is the intended behaviour. That is also why
from grapharc.session.demo import GRAPH_NAME appears in both children: the
import is the registration.
session.interrupt(reason). It is a durable row, not a signal, so it works from
another thread or another process; the process driving the session reads it
after the next superstep.
import tempfile
import threading
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.session import SessionManager, SessionState, register_graph
INSIDE_STEP_ONE = threading.Event()
# `one` waits for this before returning, so the stop is durably recorded
# *before* the superstep boundary that reads it. Posting the interrupt and
# hoping it wins the race against `two` starting is what makes this snippet
# flaky rather than illustrative.
STOP_RECORDED = threading.Event()
class State(SessionState):
log: list[str] = []
def build(checkpointer=None):
def one(state: State) -> dict:
INSIDE_STEP_ONE.set()
STOP_RECORDED.wait(timeout=10)
return {"log": [*state.log, "one"]}
def two(state: State) -> dict:
return {"log": [*state.log, "two"]}
def three(state: State) -> dict:
return {"log": [*state.log, "three"]}
g = GraphARC(State, name="pipeline")
for name, fn in (("one", one), ("two", two), ("three", three)):
g.add_node(name, fn, writes={"log"})
g.add_edge(START, "one")
g.add_edge("one", "two")
g.add_edge("two", "three")
g.add_edge("three", END)
return g.compile(checkpointer=checkpointer)
register_graph("pipeline", build, replace=True)
with SessionManager(tempfile.mkdtemp(prefix="cookbook-")) as manager:
session = manager.create("pipeline", session_id="s1")
def stopper():
INSIDE_STEP_ONE.wait(timeout=10)
session.interrupt("operator asked for a stop")
STOP_RECORDED.set()
threading.Thread(target=stopper).start()
turn = session.run({})
print("status :", turn.status.value)
print("interrupted :", turn.interrupted_by)
print("ran :", turn.nodes)
print("still pending:", turn.pending)
resumed = session.run()
print("after resume :", resumed.nodes, resumed.state["log"])status : interrupted
interrupted : operator asked for a stop
ran : ('one',)
still pending: ('two',)
after resume : ('two', 'three') ['one', 'two', 'three']
An interrupt does not stop a running node. It is read at the next
superstep boundary — after the whole parallel step has finished and been
checkpointed, not after each node in it. So a node already inside its body runs
to completion, and so does everything running beside it. In this snippet the
stop is posted while one is executing and takes effect before two starts —
but only because one waits for STOP_RECORDED before returning. Without that
handshake the interrupt is racing the superstep boundary, and landing a few
microseconds late means it takes effect after two instead. That is a real
property of interrupts, not an artefact of the example: the boundary an
interrupt is read at is the next one after it is written, and you do not control
which one that is unless you synchronise. Cutting a node off mid-flight is
what Budget(max_seconds=...) is for, and that has its own honest limits.
A stop queued while the session is asleep is honoured before the next turn's first node — an interrupt is never silently lost.
It means one task, and you cannot tell which. A Send fan-out can put the same
gated node on the boundary several times over; each of those tasks is held
separately and each needs its own decision, so the count is exact and nothing
runs unapproved — but the requests are indistinguishable, right down to the
action text.
import operator
import tempfile
from typing import Annotated
from pydantic import BaseModel
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.session import SessionManager, SessionState, register_graph
class State(SessionState):
recipients: list[str] = []
sent: Annotated[list[str], operator.add] = []
class Envelope(BaseModel):
to: str
def build(checkpointer=None):
def prepare(state: State) -> dict:
return {}
def dispatch(state: State) -> list[tuple[str, BaseModel]]:
return [("send", Envelope(to=who)) for who in state.recipients]
def send(payload: Envelope) -> dict:
return {"sent": [payload.to]}
g = GraphARC(State, name="mailshot")
g.add_node("prepare", prepare, writes=set())
g.add_node("send", send, writes={"sent"}, input_schema=Envelope)
g.add_edge(START, "prepare")
g.add_fanout_edge("prepare", dispatch)
g.add_edge("send", END)
return g.compile(checkpointer=checkpointer)
register_graph("mailshot", build, approval_nodes=("send",), replace=True)
with SessionManager(tempfile.mkdtemp(prefix="cookbook-")) as manager:
session = manager.create("mailshot", session_id="s1")
turn = session.run({"recipients": ["ana@x", "bo@x", "cy@x"]})
print("status :", turn.status.value)
for hold in turn.approvals:
print(f" hold {hold.node!r}: {hold.action!r}")
# Approve two, reject one. Which recipient loses is not yours to choose.
holds = session.pending_approvals
session.decide(approved=True, request_id=holds[0].id)
session.decide(approved=True, request_id=holds[1].id)
session.decide(approved=False, request_id=holds[2].id, reason="bounced")
final = session.run()
print("ran :", final.nodes)
print("skipped:", final.skipped)
print("sent :", sorted(final.state["sent"]))status : awaiting_approval
hold 'send': "run node 'send'"
hold 'send': "run node 'send'"
hold 'send': "run node 'send'"
ran : ('send', 'send')
skipped: ('send',)
sent : ['bo@x', 'cy@x']
Three identical holds. Approving k of n runs k of them; which k is
not yours to pick. Here ana@x lost, and that is an artefact of task ordering,
not a guarantee. If the choice matters — "send to ana but not to cy" — do not
model it as one gated node fanned out. Gate a node that reads the decision from
state, or fan out over distinct node names.
Two more things this snippet demonstrates in passing:
request_idis required once more than one hold is open.decide()without it raises rather than guessing, because guessing is how an operator signs offsend_emailand releasesdelete_records.- Only one verdict reaches the graph.
SessionState.approval_decisionis a single channel, so a boundary settling several holds shows the graph the first hold's verdict. Every verdict is on the event log and inTurnResult.decisions.
Sharp edge: a Send payload that is a Pydantic model goes into the
checkpoint, and LangGraph prints a warning on stderr when it reads one back
(Deserializing unregistered type <module>.Envelope from checkpoint. This will be blocked in a future version.). It is stderr noise today and a future error;
the fix is LangGraph's allowed_msgpack_modules, not anything in GraphARC.
grapharc.server.create_app(registry=...) returns a FastAPI app. A request may
name a graph the operator registered and supply input and a budget; it may not
describe a graph. The snippet below drives the real app through Starlette's
TestClient, so it runs with no server process.
import json
import time
from fastapi.testclient import TestClient
from langchain_core.messages import HumanMessage
from grapharc.observe.trace import TraceRecorder
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.server import GraphRegistry, create_app
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
answer: str = ""
def build_qa(trace: TraceRecorder):
model = ScriptedChatModel(responses=["Budgets cap iterations, tokens and time."])
def answer(state: State) -> dict:
reply = model.invoke([HumanMessage(content=state.question)])
return {"answer": str(reply.content)}
g = GraphARC(State, name="qa", trace=trace)
g.add_node("answer", answer, writes={"answer"})
g.add_edge(START, "answer")
g.add_edge("answer", END)
return g.compile()
registry = GraphRegistry({"qa": build_qa})
app = create_app(registry=registry)
with TestClient(app) as client:
print("health :", client.get("/healthz").json())
created = client.post(
"/sessions", json={"graph": "qa", "input": {"question": "how do budgets work?"}}
)
session_id = created.json()["id"]
print("created:", created.status_code, created.json()["status"])
# Poll until the run reaches a terminal status.
for _ in range(200):
view = client.get(f"/sessions/{session_id}").json()
if view["status"] in ("succeeded", "failed", "interrupted"):
break
time.sleep(0.01)
print("status :", view["status"])
print("answer :", view["result"]["answer"])
usage = view["usage"]
print("usage :", usage["iterations"], "iterations,", usage["tokens"], "tokens")
ack = client.post(
f"/sessions/{session_id}/events", json={"type": "message", "data": {}}
).json()["event"]
print("event :", ack["applied"], ack["detail"])
# SSE: replay from cursor 0, so a finished run still streams its whole trace.
frames = [
line for line in client.get(f"/sessions/{session_id}/stream").text.splitlines() if line
]
print("frames :", [f for f in frames if f.startswith("event:")])
trace = client.get(f"/sessions/{session_id}/trace").text
print("trace :", [json.loads(line)["phase"] for line in trace.splitlines()])health : {'status': 'ok', 'version': '0.1.7', 'graphs': ['qa']}
created: 201 queued
status : succeeded
answer : Budgets cap iterations, tokens and time.
usage : 1.0 iterations, 15.0 tokens
event : False recorded: this runtime does not deliver 'message' events into a running graph (ROADMAP §6.4 event queue / §6.5 approval node)
frames : ['event: trace', 'event: trace', 'event: trace', 'event: status', 'event: done']
trace : ['topology', 'start', 'end']
The routes:
| Route | What it does |
|---|---|
POST /sessions |
create and start one → 201 + location header |
GET /sessions |
list every session this process knows |
GET /sessions/{id} |
status, result, live meter reading |
POST /sessions/{id}/events |
message / approval / interrupt → 202 |
GET /sessions/{id}/stream |
SSE: trace frames, then status, then done |
GET /sessions/{id}/trace |
the JSONL trace as application/x-ndjson |
GET /healthz |
liveness, version, registered graph names |
Read applied, not the status code. POST /events answers 202 for
everything — the event was accepted for the session. Whether it changed the
run is event.applied in the body, and this runtime only applies interrupt.
A message or approval is recorded with applied: false and a detail
saying why. An HTTP 2xx here does not mean the run was steered.
A stream frame and a trace line are the same record. BroadcastRecorder
shapes each event once and hands the same dict to the file and to the
subscriber, so parsing a line of /trace yields exactly the object the SSE
frame carried — including the trace format's 2000-character clip on long values.
The unclipped answer is the session's result, which is never truncated.
The stream takes a cursor query parameter and honours last-event-id, so a
dropped SSE connection resumes without replaying frames you already saw.
What this default runtime does not do: it does not resume across a process
restart (sessions live in this process's memory; the trace files outlive it, the
sessions do not), and it does not evict — every session and its event list is
retained for the life of the process. For durable sessions, use
grapharc.session as shown above.
An async one, if your nodes are async def. The server drives runs through
astream, and SqliteSaver — this repo's own dependency, and what
grapharc.session uses — has no async side.
The runtime probes the saver once per run, on the run's own event loop, before
anything executes. A sync-only saver quietly selects the sync driver instead.
The one combination nothing can serve is async def nodes plus a sync-only
saver, and that is where you get CheckpointerNotAsyncError.
import asyncio
import sqlite3
import tempfile
import time
from pathlib import Path
from fastapi.testclient import TestClient
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.checkpoint.sqlite import SqliteSaver
from grapharc.observe.trace import TraceRecorder
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.server import GraphRegistry, create_app
class State(GraphARCState):
question: str
answer: str = ""
async def answer(state: State) -> dict:
await asyncio.sleep(0)
return {"answer": "42"}
def graph_with(checkpointer, trace: TraceRecorder):
g = GraphARC(State, name="qa", trace=trace)
g.add_node("answer", answer, writes={"answer"})
g.add_edge(START, "answer")
g.add_edge("answer", END)
return g.compile(checkpointer=checkpointer)
db = Path(tempfile.mkdtemp(prefix="cookbook-")) / "checkpoints.sqlite"
conn = sqlite3.connect(db, check_same_thread=False)
registry = GraphRegistry(
{
"sync_saver": lambda trace: graph_with(SqliteSaver(conn), trace),
"async_ok": lambda trace: graph_with(InMemorySaver(), trace),
}
)
def run(client, graph):
sid = client.post("/sessions", json={"graph": graph, "input": {"question": "q"}}).json()["id"]
for _ in range(500):
view = client.get(f"/sessions/{sid}").json()
if view["status"] in ("succeeded", "failed", "interrupted"):
return view
time.sleep(0.01)
raise AssertionError("never finished")
with TestClient(create_app(registry=registry)) as client:
bad = run(client, "sync_saver")
print("sync_saver :", bad["status"])
print("error :", bad["error"])
good = run(client, "async_ok")
print("async_ok :", good["status"], good["result"])
conn.close()sync_saver : failed
error : CheckpointerNotAsyncError: graph 'qa' needs astream() because it has async nodes, but its checkpointer cannot be driven asynchronously (SqliteSaver does not implement async checkpointing: The SqliteSaver does not support async methods. Consider using AsyncSqliteSaver instead.). Fix either side: give compile() a saver that implements the async API — langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver (needs aiosqlite, and must be constructed on a running loop) or langgraph.checkpoint.memory.InMemorySaver — or make the graph's nodes synchronous, which lets the server drive it through stream() and use the sync saver as it is. [graph 'qa' has async nodes ['answer']; use astream(), because stream() has no event loop to await them on]
async_ok : succeeded {'question': 'q', 'answer': '42'}
So, in a table:
| Nodes | Checkpointer | Result |
|---|---|---|
| sync | none / SqliteSaver |
runs (sync driver) |
| sync | InMemorySaver / AsyncSqliteSaver |
runs (async driver) |
async def |
none / InMemorySaver / AsyncSqliteSaver |
runs (async driver) |
async def |
SqliteSaver |
CheckpointerNotAsyncError |
AsyncSqliteSaver needs aiosqlite and must be constructed on a running loop.
InMemorySaver is the zero-dependency answer when durability is not the point.
grapharc serve --registry module:attr, where the attribute is a
grapharc.server.GraphRegistry (or a callable returning one). Without
--registry the app starts with an empty registry — a legitimate way to check
the process comes up and a useless way to run anything, and the CLI tells you
which of the two you got.
"""mygraphs.py — graphs this server is allowed to run."""
from langchain_core.messages import HumanMessage
from grapharc.observe.trace import TraceRecorder
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.server import GraphRegistry
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
answer: str = ""
def build_qa(trace: TraceRecorder):
model = ScriptedChatModel(responses=["Budgets cap iterations, tokens and time."])
def answer(state: State) -> dict:
return {"answer": str(model.invoke([HumanMessage(content=state.question)]).content)}
g = GraphARC(State, name="qa", trace=trace)
g.add_node("answer", answer, writes={"answer"})
g.add_edge(START, "answer")
g.add_edge("answer", END)
return g.compile()
REGISTRY = GraphRegistry({"qa": build_qa})Then, in a shell. This transcript is a real one: the ids and timestamps differ
per run, and the only editing is the ... marking where a timestamp or a long
JSON body was cut, plus the session view re-indented for reading — nothing was
reworded. The test re-runs every command here against a fresh server and
requires each to succeed; the bytes below are one run's.
$ PYTHONPATH=. grapharc serve --registry mygraphs:REGISTRY --port 8124
serving grapharc.server on http://127.0.0.1:8124
graphs : qa
ctrl-c to stop
$ curl -s localhost:8124/healthz
{"status":"ok","version":"0.1.7","graphs":["qa"]}
$ curl -s -X POST localhost:8124/sessions -H 'content-type: application/json' \
-d '{"graph":"qa","input":{"question":"how do budgets work?"}}'
{"id":"bf5ca55bff7b480f","graph":"qa","thread_id":"bf5ca55bff7b480f","status":"queued", ...}
$ curl -s localhost:8124/sessions/bf5ca55bff7b480f
{
"id": "bf5ca55bff7b480f",
"graph": "qa",
"status": "succeeded",
"run_id": "0d9dce7f61c4",
"result": {
"question": "how do budgets work?",
"answer": "Budgets cap iterations, tokens and time."
},
"usage": {"iterations": 1.0, "tokens": 15.0, "elapsed_seconds": 0.003},
...
}
$ curl -s localhost:8124/sessions/bf5ca55bff7b480f/trace
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "topology", "phase": "topology", "step": 0, "state_delta": {"nodes": ["answer"], "edges": [["__start__", "answer", "static"], ["answer", "__end__", "static"]]}}
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "answer", "phase": "start", "step": 1}
{"ts": "...", "run_id": "0d9dce7f61c4", "thread_id": "bf5ca55bff7b480f", "attempt": 1, "graph": "qa", "node": "answer", "phase": "end", "step": 1, "state_delta": {"answer": "Budgets cap iterations, tokens and time."}, "duration_ms": 1.0288769999533542, "tokens": 15}The banner is printed before the server blocks, so a script watching stdout
for the URL does not have to wait for the process to exit to learn it. serve
needs the server extra: uv sync --extra server.
To use a real model instead of the scripted one, swap the model in the builder:
from grapharc.gateway import get_model
model = get_model("openrouter/anthropic/claude-haiku-4.5")
# or, on a Claude subscription with the CLI on PATH and no API key:
model = get_model("claude-cli/claude-sonnet-5")This snippet was not executed — it needs a paid API key or a Claude
subscription. grapharc models --check tells you which backends this machine
could use, without contacting any provider.
grapharc serve --live-root PATH mounts a read-only live view at /live over
the trace files under PATH — including files other processes are appending
right now. Traces are append-only JSONL written line-at-a-time under a lock,
so a reader that stops at the last complete newline (TailRecorder, in
grapharc.observe.trace) can follow a run another process is executing;
that is exactly what the view does.
GET /live lists every *.jsonl under the root, newest first.
GET /live/view?trace=REL is the page: it opens
GET /live/api/stream?trace=REL (server-sent events) and receives a fresh
snapshot — the run's Mermaid diagram, metrics-style numbers, cost, and
status — each time the file grows. The server recomputes the snapshot;
the page only renders it. Add &run=ID to pin one run in a file that holds
several; without it the view follows the newest.
A planner run has no graph for as long as it takes the model to propose one,
so the snapshot also carries a planning block — round number, proposal size,
admitted or rejected with the checks that failed, planner tokens spent — and
the page renders it as a panel from the plan, admission and round events
already in the trace. A run refused on every round (admission_refused) never
produces a topology at all; it shows its rounds and its stop reason instead of
an empty page. A round that has begun and not closed also counts as activity,
because a planner mid-inference writes nothing for a minute at a time.
GET /live/view?trace=REL&replay=1 replays a finished trace instead of
rendering its final state: the recorded events are walked in timestamp order
and each one emits the snapshot a live run would have sent, so nodes go amber
then green in the order and at the pace they really ran. &speed=N divides the
wall clock, and a whole replay is capped at 40 seconds however slow the
recording was, so yesterday's 40-minute incident trace is watchable. Without
the parameter nothing changes: one snapshot per file change, as before.
This composes with the Slack bot, which gives every tracing command a trace
path under its working directory: run grapharc serve --live-root over that
same directory, set GRAPHARC_SLACK_LIVE_URL, and the bot posts a
"watch live" link when a run starts — the walkthrough is in
07-slack.md. It also composes with this page's own server
sessions: point --live-root at the session root and each
<session>/trace.jsonl gets a page.
The posture is the same as everything else in grapharc.observe: the view is
derived from the trace file and nothing else, and it is read-only. Requested
paths are confined inside the root (escapes are 404s), and state_delta
contents — arbitrary node writes — are never serialized into any live
response; the exposure is what viz already prints. The bind stays
127.0.0.1 unless you say otherwise; binding wider prints a warning, because
reachability is meant to come from a tunnel or tailnet in front, optionally
with --live-token TOKEN (or GRAPHARC_LIVE_TOKEN) required on every
/live request.
Where that token is allowed to travel matters. A URL is copied into places
with much weaker access control than the traces it protects: the uvicorn
request line, an nginx access log, browser history, and the referrer of
anything the page links out to. So the token goes in an
Authorization: Bearer TOKEN header, or in the cookie that POST /live/auth
sets when you paste it into the sign-in page a browser gets instead of a 401.
?token= is accepted on /live/api/stream and nowhere else — a browser
EventSource cannot set a header, so that one route has no alternative — and
any other /live route refuses a query-string token with a 401 that says so
rather than accepting the secret into your logs. The tradeoff that remains:
the SSE request line still carries the token, so if you terminate TLS at nginx
and log request URIs, scrub token= from that one path (or log
$request_method $uri rather than $request). The cookie is a digest of the
token, not the token, is HttpOnly and SameSite=Strict, and is scoped to
/live. Sign-in and the SSE exemption both apply only when a token is
configured at all; without one, nothing about /live is authenticated.
The page draws the graph itself — a positioned SVG the server computes from
the trace, with per-node status, tokens, recorded cost and duration on every
node — and makes no external request at all: styles, script and rendering all
ship inside the package, so it works with the network cable pulled. A finished
run gets a playback bar (play, speed, and a scrubber over the run's own
timeline), and ?replay=1&speed=N on the stream URL re-emits the recorded
snapshots server-side at recorded speed. The raw Mermaid source and the same
mermaid.live fragment link the Slack bot can post stay one click away under
"diagram source". A parked --approve run shows its goal in the header, the
proposed nodes in violet, and a copy-ready grapharc approve <dir> banner.
The guided pairing: grapharc plan/go default their trace to
.grapharc/runs/<stamp>/trace.jsonl under the current directory, and
grapharc serve --live-root .grapharc/runs writes a discovery marker
(.grapharc/live-server.json — URL, root, pid; never the token) that lets
those commands print the exact watch : URL for each run. The marker is
validated with one loopback connect at print time, so a marker left by a
crashed server downgrades the line to an instruction instead of a dead link,
and it is removed on clean shutdown.
replay(trace, run_id). It is a reconstruction, not a re-execution: it reads
the JSONL and rebuilds the node sequence, the deltas, the timings and the
failures. Nothing here calls a model, a tool, or a node.
import tempfile
from pathlib import Path
from langchain_core.messages import HumanMessage
from grapharc.observe import TraceRecorder, format_replay, replay
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
draft: str = ""
answer: str = ""
def build(trace: TraceRecorder, responses: list[str]):
model = ScriptedChatModel(responses=responses, on_exhausted="repeat")
def draft(state: State) -> dict:
return {"draft": str(model.invoke([HumanMessage(content=state.question)]).content)}
def polish(state: State) -> dict:
return {"answer": state.draft.strip().rstrip(".") + "."}
g = GraphARC(State, name="writer", trace=trace)
g.add_node("draft", draft, writes={"draft"})
g.add_node("polish", polish, writes={"answer"})
g.add_edge(START, "draft")
g.add_edge("draft", "polish")
g.add_edge("polish", END)
return g.compile()
path = Path(tempfile.mkdtemp(prefix="cookbook-")) / "trace.jsonl"
trace = TraceRecorder(path)
build(trace, ["budgets cap iterations"]).invoke({"question": "budgets?"}, run_id="before")
run = replay(trace, "before")
print("path :", run.path)
print("tokens :", run.tokens)
print("state :", run.replay_state())
print()
print(format_replay(run))path : ['draft', 'polish']
tokens : 7
state : {'draft': 'budgets cap iterations', 'answer': 'budgets cap iterations.'}
run before · graph writer
1 ok draft (0.4ms, 7 tok)
draft = 'budgets cap iterations'
2 ok polish (0.0ms)
answer = 'budgets cap iterations.'
2 nodes · 7 tokens · stopped: not recorded
(Node durations are measured wall-clock; yours will differ.)
Passing run_id= to invoke() is what makes a run findable later. Without it
you get a random hex id and have to fish it out of the trace file.
replay_state() is only as good as the deltas. Two limits, both from the
recording side:
- The trace truncates a string past 2000 characters at write time, so a long value replays truncated.
- The trace does not record which state fields have reducers. A field LangGraph
appended to replays as last-write-wins unless you say otherwise:
run.replay_state(reducers={"log": operator.add}). Forgetting this silently under-reports an accumulating list, which is why the argument is in the signature rather than in a comment.
replay_thread(trace, thread_id) gives you every run recorded against one
thread, which is how you read a resumed session rather than its last attempt.
diff_trace(trace, run_a, run_b) aligns the two node sequences with difflib
and reports where they diverged.
import tempfile
from pathlib import Path
from langchain_core.messages import HumanMessage
from grapharc.observe import TraceRecorder, diff_trace, format_diff
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
draft: str = ""
answer: str = ""
def build(trace: TraceRecorder, responses: list[str]):
model = ScriptedChatModel(responses=responses, on_exhausted="repeat")
def draft(state: State) -> dict:
return {"draft": str(model.invoke([HumanMessage(content=state.question)]).content)}
def polish(state: State) -> dict:
return {"answer": state.draft.strip().rstrip(".") + "."}
g = GraphARC(State, name="writer", trace=trace)
g.add_node("draft", draft, writes={"draft"})
g.add_node("polish", polish, writes={"answer"})
g.add_edge(START, "draft")
g.add_edge("draft", "polish")
g.add_edge("polish", END)
return g.compile()
path = Path(tempfile.mkdtemp(prefix="cookbook-")) / "trace.jsonl"
trace = TraceRecorder(path)
build(trace, ["budgets cap iterations"]).invoke({"question": "budgets?"}, run_id="before")
build(trace, ["budgets cap iterations, tokens and time"]).invoke(
{"question": "budgets?"}, run_id="after"
)
diff = diff_trace(trace, "before", "after")
print("identical:", diff.identical)
print(format_diff(diff))identical: False
before != after: 2 node(s) wrote different deltas; 2 state field(s) differ
draft: changed ['draft']
polish: changed ['answer']
state answer: 'budgets cap iterations.' -> 'budgets cap iterations, tokens and time.'
state draft: 'budgets cap iterations' -> 'budgets cap iterations, tokens and time'
identical deliberately ignores timing and tokens. Two runs of a
deterministic graph differ by milliseconds every time, and a diff that is never
clean is a diff nobody reads. It compares path, deltas and termination reason.
The token and duration numbers are still on the RunDiff if you want them.
Diffing runs of different graphs raises ReplayError rather than producing an
alignment that is technically correct and means nothing.
attribute(trace, run_id, rates=...) splits a run's spend per node. A
RateCard is USD per 1,000 tokens, blended across input and output, because
a trace event records one tokens total and no split.
import tempfile
from pathlib import Path
from langchain_core.messages import HumanMessage
from grapharc.observe import RateCard, TraceRecorder, attribute, summarize, to_mermaid
from grapharc.runtime.graph import END, START, GraphARC
from grapharc.runtime.state import GraphARCState
from grapharc.testing import ScriptedChatModel
class State(GraphARCState):
question: str
draft: str = ""
answer: str = ""
path = Path(tempfile.mkdtemp(prefix="cookbook-")) / "trace.jsonl"
trace = TraceRecorder(path)
model = ScriptedChatModel(responses=["budgets cap iterations"], on_exhausted="repeat")
def draft(state: State) -> dict:
return {"draft": str(model.invoke([HumanMessage(content=state.question)]).content)}
def polish(state: State) -> dict:
return {"answer": str(model.invoke([HumanMessage(content=state.draft)]).content) + "."}
g = GraphARC(State, name="writer", trace=trace)
g.add_node("draft", draft, writes={"draft"})
g.add_node("polish", polish, writes={"answer"})
g.add_edge(START, "draft")
g.add_edge("draft", "polish")
g.add_edge("polish", END)
g.compile().invoke({"question": "budgets?"}, run_id="r1")
cost = attribute(trace, "r1", rates=RateCard(default=3.0)) # USD per 1k tokens, blended
for node in cost.per_node:
print(f"{node.node:<8} {node.tokens:>3} tok ${node.estimated_cost_usd:.6f}")
print("total ", cost.tokens, "tok complete:", cost.complete)
metrics = summarize(trace, "r1")
print("metrics :", metrics.nodes_executed, "nodes,", metrics.tokens, "tokens,", metrics.per_node)
print()
print(to_mermaid(trace, "r1"))draft 7 tok $0.021000
polish 10 tok $0.030000
total 17 tok complete: True
metrics : 2 nodes, 17 tokens, {'draft': 1, 'polish': 1}
flowchart TD
n0["draft"]
n1["polish"]
start((start)) --> n0["draft"]
n0["draft"] --> n1["polish"]
n1["polish"] --> fin((end))
classDef done fill:#d3f2d3,stroke:#2f7d32
classDef running fill:#fff3cd,stroke:#b8860b
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
classDef errored fill:#f8d7da,stroke:#b02a37
class n0,n1 done
RunCost.tokens and RunMetrics.tokens agree by construction — both count the
end events of node executions, and the test suite asserts they match, because
a cost report and an audit trail that disagree are worse than either alone.
Three honest limits:
- Nothing in GraphARC writes
cost_usdonto a trace event today. The field exists andTraceRecorder.eventaccepts it, but the kernel does not pass it, sorecorded_cost_usdis alwaysNoneon a trace from today's runtime. Everything you see above isestimated_cost_usd— tokens × your rate card, reported in its own field so nobody mistakes an estimate for an invoice. completeis what tells you the total is a total. Tokens with neither a recorded cost nor a matching rate are counted inunpriced_tokens, and a non-zero count meanscost_usdis a lower bound. ARateCardwith nodefaultand no matching model prices nothing.- A node that raised has no token count at node level. No
endevent was written. Where it was anAgentNode, its per-call sub-events still hold the spend, reported astokens_before_error— kept out of the total so the total keeps matchingmetrics.
to_mermaid renders the graph's declared topology — the topology event every
run now writes — with execution status overlaid per node: done, running,
errored, or still pending. Branches not taken stay on the diagram in grey,
conditional routes draw dotted, and a multi-round planner run gets one cluster
per admitted round. A trace with no topology event (an AgentNode driven with
no enclosing graph, or a file written before the event existed) falls back to
the executed path in event order, keyed by (node, step) so parallel instances
of a fan-out worker are distinct boxes. Paste either form into any Markdown
renderer that speaks Mermaid.
attribute_thread(trace, thread_id) is the same for a whole session across
resumes, and by_node(trace) ranks every node in a file by cost.
Twelve commands. Every one takes --json, which prints the same payload as one
document on stdout — including failures, which become the document rather than a
line on stderr.
| Command | What it is for |
|---|---|
grapharc demo <example> |
run a built-in example graph (stage0…stage6, capstone) |
grapharc run <graph.json> |
run a topology you wrote, through the admission gate; --check-only lints it |
grapharc plan <goal> |
governed loop: propose → admit → execute → replan; --approve parks each admitted round for a human |
grapharc approve <trace> |
answer a plan run waiting on its approval gate (--deny to refuse) |
grapharc agent <task> |
run an agent node with the core tools against a task |
grapharc serve |
run the HTTP API |
grapharc models [spec] |
what a spec resolves to; --check probes this machine |
grapharc replay <trace> <run> |
reconstruct a recorded run |
grapharc diff <trace> <a> <b> |
compare two runs in one trace |
grapharc trace <trace> |
pretty-print a trace file |
grapharc metrics <trace> <run> |
summarize one run |
grapharc viz <trace> <run> |
render the executed path as Mermaid |
Exit codes are part of the interface: 0 did the job, 1 ran and the answer
was negative (two runs differed, a run id had no events, no backend was usable),
2 could not run at all (missing file, missing component, unknown model spec).
A whole session, verbatim (run ids are random per run and durations are wall-clock; the test maps the former, masks the latter, and byte-compares every other character):
$ grapharc demo stage1 --trace trace.jsonl
...
no_progress_rounds: 0
proposal: verifier
candidate: 2
termination_reason: target_met
trace: trace.jsonl
$ grapharc trace trace.jsonl --json | jq -r '.events[0].run_id'
2a47f18064b7
$ grapharc trace trace.jsonl --run-id 2a47f18064b7 | head -6
[ 0] topology topology Δ{'nodes': ['start', 'plan', 'act', 'verify', 'finish_target_met', 'finish_max_iterations', 'finish_no_progress'], 'edges': [['__start__', 'start', 'static'], ['start', 'plan', 'static'], ['plan', 'act', 'static'], ['act', 'verify', 'static'], ['finish_target_met', '__end__', 'static'], ['finish_max_iterations', '__end__', 'static'], ['finish_no_progress', '__end__', 'static'], ['verify', 'plan', 'conditional'], ['verify', 'finish_target_met', 'conditional'], ['verify', 'finish_max_iterations', 'conditional'], ['verify', 'finish_no_progress', 'conditional']]}
[ 1] start start
[ 1] start end Δ{'pending': ['budgets', 'verifier']}
[ 2] plan start
[ 2] plan end Δ{'proposal': 'budgets', 'round': 1}
[ 3] act start
$ grapharc metrics trace.jsonl 2a47f18064b7
run_id: 2a47f18064b7
graph: stage1_loop
nodes_executed: 8
errors: 0
tokens: 81
duration_ms: 0.68
attempts: 1
termination_reason: target_met
per_node: {'start': 1, 'plan': 2, 'act': 2, 'verify': 2, 'finish_target_met': 1}
events: 17
per_phase: {'topology': 1, 'start': 8, 'end': 8}
$ grapharc viz trace.jsonl 2a47f18064b7
flowchart TD
n0["start"]
n1["plan"]
n2["act"]
n3["verify"]
n4["finish_target_met"]
n5["finish_max_iterations"]
n6["finish_no_progress"]
start((start)) --> n0["start"]
n0["start"] --> n1["plan"]
n1["plan"] --> n2["act"]
n2["act"] --> n3["verify"]
n4["finish_target_met"] --> fin((end))
n5["finish_max_iterations"] --> fin((end))
n6["finish_no_progress"] --> fin((end))
n3["verify"] -.-> n1["plan"]
n3["verify"] -.-> n4["finish_target_met"]
n3["verify"] -.-> n5["finish_max_iterations"]
n3["verify"] -.-> n6["finish_no_progress"]
classDef done fill:#d3f2d3,stroke:#2f7d32
classDef running fill:#fff3cd,stroke:#b8860b
classDef pending fill:#eeeeee,stroke:#999999,color:#666666
classDef errored fill:#f8d7da,stroke:#b02a37
class n0,n1,n2,n3,n4 done
class n5,n6 pending
$ grapharc replay trace.jsonl 2a47f18064b7 | tail -4
pending = []
8 ok finish_target_met (0.0ms)
termination_reason = 'target_met'
8 nodes · 81 tokens · stopped: target_met
$ grapharc demo stage1 --trace trace.jsonl > /dev/null # a second run, same file
$ grapharc diff trace.jsonl 2a47f18064b7 3c0d1b4b4b3e; echo "exit $?"
2a47f18064b7 == 3c0d1b4b4b3e: same path (8 nodes), same state
exit 0Sharp edge: grapharc run does not print the run id. It prints the trace
path, and you get the id out of the file — the jq line above, or
python -c "import json; print(json.loads(open('trace.jsonl').readline())['run_id'])".
Everything downstream (metrics, viz, replay, diff) wants that id.
--json on any of them, and on failures too:
$ grapharc metrics trace.jsonl 2a47f18064b7 --json
{
"ok": true,
"command": "metrics",
"run_id": "2a47f18064b7",
"graph": "stage1_loop",
"nodes_executed": 8,
"errors": 0,
"tokens": 81,
"duration_ms": 0.75,
"attempts": 1,
"termination_reason": "target_met",
"per_node": {
"start": 1,
"plan": 2,
"act": 2,
"verify": 2,
"finish_target_met": 1
},
"events": 17,
"per_phase": {
"topology": 1,
"start": 8,
"end": 8
}
}
$ grapharc metrics nope.jsonl abc --json; echo "exit $?"
{
"ok": false,
"command": "metrics",
"error": "no such trace file: nope.jsonl"
}
exit 2grapharc models needs no credentials to answer what a spec resolves to:
$ grapharc models openrouter/anthropic/claude-haiku-4.5
spec: openrouter/anthropic/claude-haiku-4.5
backend: openrouter
model: anthropic/claude-haiku-4.5--check reports the machine it is run on:
$ grapharc models --check
claude-cli usable 'claude' on PATH at /home/you/.local/bin/claude
credential: claude subscription login (no API key)
openrouter unusable no API key (set OPENROUTER_API_KEY, or add one to .env)
credential: <unset>
openai unusable no API key (set OPENAI_API_KEY, or add one to .env)
credential: <unset>
ollama usable local server at http://localhost:11434/v1
credential: none needed (local server)
mock usable scripted test double; never reaches a provider
local probe only — no provider was contacted, so a configured key
is not a validated one.(Paths in that output are machine-specific; the one above is edited only to
replace a home directory.) --check is a local probe: it reports that a
credential is configured, never that it is valid, in credit, or entitled to a
model. It exits 1 when no real provider is usable — mock alone does not
count.
grapharc agent is the one command in this list that always needs a model, so
there is no scripted form of it and nothing here claims to have run it:
$ grapharc agent "summarise README.md" --workspace ./work --max-turns 6 --json--allow / --deny / --ask are repeatable tool-name globs (--deny wins),
--executor local drops the sandbox, and --max-turns / --max-tokens /
--max-seconds are the run's budget. See the governance and harness sections
for what those actually enforce.