GraphARC
A graph engineering toolkit on LangGraph: typed state contracts, per-node write permissions, enforced budgets, and JSONL traces that double as replay points. A planner proposes, a deterministic checker admits, and only then does anything execute.
$ pip install grapharc $ grapharc demo stage0 # costs nothing, needs no key
Chapters
What is GraphARC?
GraphARC is "a governed agent runtime built on LangGraph" for building production-grade multi-agent systems with built-in safety, auditability, and control. It adds a governance layer over LangGraph: a planner proposes a subgraph, a deterministic checker admits it, and only then does anything execute. Every transition is permitted, every loop is bounded, and afterwards you can prove what happened and why it stopped. Status is early days (0.1.1) β the API is not stable yet. Badges: PyPI, Python versions, CI, MIT license.
pip install grapharc
What makes GraphARC different
Six bullets: Admission gate β all runtime topology changes go through deterministic approval before execution; Typed state contracts β Pydantic-based state validation at every node boundary; Per-node write permissions β explicit control over what each node can modify; Enforced budgets β token spend and execution time limits with hard cutoffs; Complete auditability β JSONL traces that serve as replay points and proof of execution; Governed topology β new nodes and edges proposed at runtime are checked before being built. The framing is graph engineering: when one agent loop stops being enough, coordination becomes the engineering. Nodes do work (agent loops, model calls, deterministic functions, humans approving things), edges decide what runs next, and a typed shared state flows between them. The lineage cited is the July 2026 loops-vs-graphs debate (Steinberger, Ng, et al.), the "Two Graphs, Two Jobs" split, and twenty years of pre-AI graph systems where every edge means something and every path can be explained.
Quick Start
Define a Pydantic state model, create a StateGraph, add nodes and edges from START to END, compile, and invoke.
from grapharc.runtime import StateGraph from pydantic import BaseModel # Define your state class MyState(BaseModel): messages: list[str] result: str = "" # Create a graph graph = StateGraph(MyState) # Add nodes and edges graph.add_node("process", lambda state: {"result": "done"}) graph.add_edge("START", "process") graph.add_edge("process", "END") # Compile and run compiled = graph.compile() result = compiled.invoke({"messages": ["hello"]})
Architecture
A CLI or HTTP request reaches a planner, which emits a typed proposal; a deterministic admission checker either refuses it with reasons or admits it; only an admitted proposal is materialised and run by the graph kernel, on top of the model, tool and memory planes; everything lands on one JSONL record, and work discovered mid-run re-enters the gate. The amber curve along the top of the diagram is the claim: refusals return as traced reason codes, and work discovered mid-run re-enters admission β there is no already-approved path and no cached authorisation. Detailed views live in docs/diagrams/grapharc-architecture.drawio plus five more views generated from architecture.py.
Core Components
Nine components, each reachable from a shipped command.
| Component | Purpose | Module |
|---|---|---|
| Kernel | Typed state contracts, declared writes, budgets, traces, fan-out, async support | grapharc.runtime |
| Planner + Admission | Propose subgraphs, admit/reject with reasons, materialise, replan | grapharc.planner |
| Agent Node | Observe β model β permission check β sandboxed tool β repeat loop | grapharc.harness |
| Tools | Seven core tools with workspace confinement; container executor | grapharc.tools |
| Sessions | Long-lived, resumable across processes, human approval gates | grapharc.session |
| HTTP API | FastAPI + Server-Sent Events for streaming | grapharc.server |
| Policy | TOML rules over nodes, edges, tools and spend; decision audit trail | grapharc.policy |
| Memory | Durable claims with provenance, artifacts, BM25F + graph retrieval | grapharc.memory |
| Observability | Replay, run diffing, OpenTelemetry spans, cost attribution | grapharc.observe |
What it adds on top of LangGraph
Everything in the table is enforced by the library rather than left to convention, and has a test you can run. Disciplines: write permissions (undeclared write raises WritePermissionError; plain LangGraph applies it and moves on); state isolation (nodes receive state.model_copy(deep=True), so the returned dict is the only way out of a node); typed state (Pydantic with extra="forbid"; a write to a nonexistent field fails when the node is added, not when it runs); earned cycles (dag=True rejects conditional and fan-out edges when added, cycles at compile time β Stage 0 before Stage 2); code-only routing (routers are ordinary Python functions over typed state, so model prose cannot steer an edge; Command(goto=β¦) destinations are validated against the compiled graph); convergence (ProgressGuard returns the first triggered StopReason β target met / no progress / round cap); traces (JSONL start/end/error events; start carries only step identity, end adds state delta, duration and tokens, error adds duration and exception β metrics, viz, replay, diff and the OTel exporter read that same file so the dashboard and the audit trail cannot disagree); fail-closed entry points (.inner.invoke() raises MissingRunContextError); bounded work (per-run Budget of iterations/tokens/seconds/concurrency, with max_seconds delivered as an interrupt into the running node); checked state edits (update_state() rejects unknown fields, type-checks values, and with as_node= applies that node's write allowlist); governed topology.
g.add_node("answer", answer, writes={"answer"}) # undeclared writes raise
Install
Python >= 3.12. The bare package carries the kernel, the planner and the admission gate, the agent harness, the seven core tools, memory, policy and the observability commands. Backends and the HTTP API are extras because each pulls dependencies you should not pay for unless you use them. The Claude CLI backend β the default β needs no extra; it shells out to claude on your PATH. grapharc models --check reports what your machine can actually reach.
pip install grapharc # or: uv pip install grapharc grapharc demo stage0 # costs nothing, needs no key
pip install 'grapharc[openrouter]' # tool calling, structured output pip install 'grapharc[openai]' # OpenAI and OpenAI-compatible endpoints pip install 'grapharc[ollama]' # a local server pip install 'grapharc[server]' # the FastAPI + SSE HTTP API pip install 'grapharc[otel]' # OpenTelemetry span export pip install 'grapharc[all]' # every one of the above
Development install (working on GraphARC itself):
git clone https://github.com/CodeGraphContext/GraphARC cd GraphARC uv sync --group dev # Python >= 3.12 uv sync --all-extras --group dev # everything, plus the dev group
Quickstart (CLI command list
Eight demo stages plus the governed loop, agent, server and observability commands. Eleven commands, every one taking --json β in JSON mode the failure is the document rather than a line on stderr. Exit codes are part of the interface: 0 did the job, 1 ran and the answer was negative (an agent stopped short, a run id had no events, two runs differed), 2 could not run at all. The demo stages use scripted models by default, so they cost nothing and produce the same trace every time.
grapharc demo stage0 # deterministic DAG: load -> split -> count -> report grapharc demo stage1 # one earned agent loop: discover -> act -> verify -> repeat grapharc demo stage2 # typed cyclic graph: extract claims -> verify -> retry grapharc demo stage3 # bounded fan-out with failure isolation and dedup grapharc demo stage4 # bounded investigation loop with convergence guards grapharc demo stage5 # verifier: fresh context + deterministic evidence anchor grapharc demo stage6 # memory: provenance, supersession, recall grapharc demo capstone # all of the above in one research agent grapharc plan "look into the outage" # governed loop: propose -> admit -> execute -> replan grapharc run graph.json # a topology you wrote, through the same gate grapharc run graph.json --check-only # admission as a linter; executes nothing grapharc agent "fix the failing test" --workspace ./sandbox # agent + the seven core tools grapharc serve --port 8000 # the HTTP API (needs the `server` extra) grapharc models # what a model spec resolves to grapharc trace <path> # pretty-print a run trace grapharc metrics <path> <run-id> # tokens, retries, termination reason, per-node counts grapharc viz <path> <run-id> # Mermaid diagram of the executed path grapharc replay <path> <run-id> # reconstruct a run from its trace grapharc diff <path> <a> <b> # what changed between two runs
from grapharc import GraphARC, GraphARCState, Budget from grapharc.runtime.graph import START, END class State(GraphARCState): question: str answer: str = "" def answer(state: State) -> dict: return {"answer": f"42 (asked: {state.question})"} g = GraphARC(State, name="demo", budget=Budget(max_iterations=10)) g.add_node("answer", answer, writes={"answer"}) # undeclared writes raise g.add_edge(START, "answer") g.add_edge("answer", END) print(g.compile().invoke({"question": "meaning of life"}))
The admission gate
The part with no prior art to copy, and the reason the rest exists. You cannot pre-author a graph for "investigate this incident" β the shape is discovered while working β so the graph is built at runtime and a deterministic checker stands between building it and running it. The shipped planner is scripted and its first proposal names the policy-denied deploy kind, so round 1 is rejected on edge_denied and never executes while round 2 goes through the same checker and runs. The policy line ends in [registry-default] β the provenance, also on the JSON payload as policy_source β which matters because a policy can come from four places: a --policy flag, a grapharc.toml, one an LLM generated on a first run, or the registry's own default. Five checks, all of which run on every proposal, so a planner gets the complete list of objections rather than the first one: is every node's kind in the registry, is every edge permitted between the kinds it joins, does the worst case fit what is left of the budget, is the nesting within the depth limit, and is it acyclic.
$ grapharc plan "investigate the checkout outage" goal : investigate the checkout outage model : scripted registry : grapharc.examples.plan_incident:build_registry kinds : deploy, patch, triage, verify policy : grapharc.examples.plan_incident:build_registry default (deny -> deploy, otherwise allow) [registry-default] config : no grapharc.toml (flags and defaults only) stopped : goal_met (the goal check was satisfied) rounds : 2 of max 8 round 1: rejected nodes=2 executed=False rejected: edge_denied round 2: admitted nodes=3 executed=True state : goal='investigate the checkout outage' notes=['triage ran', 'patch ran', 'verify ran']
from pydantic import BaseModel from grapharc.harness.permissions import Decision from grapharc.planner import ( AdmissionChecker, CostEstimate, EdgePolicy, EdgeRule, GovernedLoop, LoopLimits, Materializer, NodeRegistry, NodeSpec, PlannerNode, ) from grapharc.runtime.budget import Budget from grapharc.testing import ScriptedChatModel class State(BaseModel): found: str = "" fixed: str = "" def factory(spec): # bodies come from HERE, never a proposal def body(state): return {"found": "cause"} if spec.name == "search" else {"fixed": "patch"} return body registry = NodeRegistry([ # the kinds a planner may propose NodeSpec(name="search", factory=factory, worst_case=CostEstimate(tokens=500)), NodeSpec(name="edit", factory=factory, worst_case=CostEstimate(tokens=2000)), NodeSpec(name="deploy", factory=factory), ]).freeze() policy = EdgePolicy(rules=( # deny -> ask -> allow, unmatched is deny EdgeRule(action=Decision.DENY, target="deploy"), EdgeRule(action=Decision.ALLOW), )) plan = '{"nodes": [{"name": "%s"}], "edges": [{"source": "__start__", "target": "%s"}]}' loop = GovernedLoop( planner=PlannerNode( ScriptedChatModel(responses=[plan % ("deploy", "deploy"), plan % ("edit", "edit")]), catalog=registry.catalog(), ), checker=AdmissionChecker(registry=registry, edge_policy=policy), materializer=Materializer( registry=registry, state_schema=State, writes={"search": {"found"}, "edit": {"fixed"}, "deploy": set()}, ), budget=Budget(max_tokens=100_000), limits=LoopLimits(max_rounds=8), goal_reached=lambda s: bool(s.fixed), ) result = loop.run("find and fix the bug", State()) print(result.stop.value) for record in result.rounds: print(record.round, record.admission.status.value, record.executed) print([r.code for r in result.rejections()])
Properties, stated rather than adjectives: Nothing runs during a check β NodeSpec.factory is never called and the budget meter is read, not written; an over-budget proposal is refused before its first node exists. A rejection is data β no "admit a reduced version", no truncated fan-out, no downgraded edge; AdmissionResult.feedback() is handed back to the planner and the loop never retries an identical proposal. Costs come from the registry, never the proposal. Every decision keys on the registry kind, never the instance name β ProposedNode(name="harmless_helper", kind="deploy") is refused by a rule denying deploy. A proposal cannot carry code β Subgraph and ProposedNode are Pydantic models with extra="forbid", so a body= or fn= key is a validation error. Materialisation binds to the authorisation β Materializer.materialize(admitted, proposal) matches by fingerprint; a result authorising something else raises NotAdmitted. Every decision is traced as a phase="admission" event carrying status, fingerprint, checks run and failed codes; deliberately not "end" so admission cannot inflate node-execution counts.
Configuration, and the zero-config path
Three flags carry every run β --registry (what may be proposed), --policy (what may connect to what), --model β and they can come from a file. Resolution is flag > env (GRAPHARC_) > grapharc.toml > built-in, and every value reports which layer supplied it: --json carries a sources block, the human view prints a config line. With nothing configured at all a run still works: grapharc.stdlib ships general-purpose node kinds β collect_context, investigate, verify, summarize, and apply_change, which is registered and denied by default. No phase anywhere is given run_command. If a model is available and no policy was named, one is generated, written to .grapharc/generated-policy.toml with a REVIEW THIS header, and reported as policy_source: generated; the second run reads it off disk as an ordinary file. Policy is generated; a registry never is β policy is data whose worst case is bad rules you can read, whereas a registry holds functions*, so generating one would mean a model writing code that then executes and the gate checking a list the gated thing wrote. The model selects from shipped kinds instead: selecting is safe, authoring is not.
# grapharc.toml [grapharc] registry = "myco.incident:build_registry" policy = "policy.toml" max_rounds = 6
The model gateway
The primary backend drives the Claude Code CLI (claude -p), so GraphARC runs on a Claude subscription with no API key. Because that CLI is a full agent, the adapter invokes it as a pure inference endpoint: every tool disallowed, no settings sources loaded, no CLAUDE.md pickup, prompt via stdin, flags via an argv array β never a shell string. An injected "run this command" has no tool to run it with. A spec is backend/model; grapharc models shows what a spec resolves to. OpenRouter carries routing: model-level fallback_models chains, provider order / sort / max_price, and per-call cost in the usage envelope; every backend reports usage in the same shape with cached input folded into the total. openrouter, openai and ollama share a base class β same tool-calling, streaming, retry policy and envelope β differing only in routing and where a price comes from.
from grapharc.gateway import get_model # Subscription, no API key β but text completion only. worker = get_model("claude-cli/claude-sonnet-5") # OpenRouter: one key for a catalog spanning most vendors, with # tool-calling, structured output, streaming, and async. worker = get_model("openrouter/anthropic/claude-haiku-4.5") reviewer = get_model("openrouter/openai/gpt-4o-mini") # a genuinely different vendor # Or go direct: your own OpenAI key, or a model on your own machine. reviewer = get_model("openai/gpt-4o-mini") # OPENAI_API_KEY local = get_model("ollama/llama3.1") # no key, no bill, no egress
grapharc demo stage5 --model openrouter/anthropic/claude-haiku-4.5 \
--reviewer-model openrouter/openai/gpt-4o-miniRetries have a policy, not a loop: three attempts by default, 0.5s initial backoff doubling to a 20s cap, jitter drawn from [0.75, 1] β shrinking rather than centred, so successive delays stay strictly increasing. A timeout, connection reset, 429 or 5xx is transient and re-issued; a 400, 401, 402, 403 or content refusal is a verdict raised on the first attempt; anything unrecognised is treated as deterministic. Retry-After raises the delay but never lowers it, and is itself capped. Cost ceilings are enforced on both sides of a call: a SpendMeter refuses before a call once the ceiling is reached, and after a call charges the cost and then raises if that crossed the line, so overspend is bounded by the single call that crossed it.
Independent verification
verify_claim is the piece worth copying even if you use none of the rest. The anchor runs before the model β the citation must appear verbatim in the source (whitespace is the only latitude), so a fabricated quote is rejected with the reviewer's call count still at zero and a hallucinated citation costs nothing. The reviewer gets a fresh context β only the claim, the quote, and a mechanically extracted window of surrounding source, never the author's conversation, which is what lets it catch a real quote lifted out of a negated sentence. Ambiguity fails closed β an unparseable reply, a non-boolean supported value, or a citation under 12 characters is a rejection. Independence is enforced in two places: build_stage5/build_capstone refuse the same object for author and reviewer, and the CLI's different_providers() compares the vendor each spec reaches, warning when the pair shares one.
Tools and the harness
Inside an agent node: task and context, recall memory, model call, then a permission decision that either denies the call back to the model, routes it to a human, or lets it reach the sandbox; execution is followed by a budget check and an evidence check before any result leaves. Every diamond is code, not prompt text, and the deny branch matters: a refused tool never reaches the sandbox and its schema is never shown to the model. Seven core tools β read_file, write_file, edit_file, list_dir, glob, grep, run_command β which grapharc agent <task> registers into a ToolRegistry that an AgentNode drives. Confinement is in the tool, not only the executor: every path argument is resolved and checked against the workspace by the tool itself, because LocalExecutor confines nothing; both a traversing ../../../etc/passwd and an absolute /etc/passwd raise WorkspaceEscape. Permissions decide which tools run β deny β ask β allow, first match wins, unmatched defaults to deny, and a denied tool's schema is invisible rather than described-then-refused. The executor bounds what a tool touches β the default runs the tool in a forked child under a CPython audit hook confining filesystem paths to the granted workspace plus the interpreter's own runtime paths, refusing sockets unless needs_network, refusing subprocess spawning outright, and SIGKILLing the whole process group on timeout. ContainerExecutor is the real boundary where one is needed: same run(spec, args) interface, one bind mount, --network none unless needs_network, all capabilities dropped, no-new-privileges, non-root uid, read-only rootfs, memory and pid limits.
Memory
Claims carry provenance β source, observation time, and the run that produced them β and corrections are recorded by supersession rather than overwrite, so a later run can see that a fact was replaced and skip the dead end. Entity resolution is Unicode-aware, so ζ±δΊ¬ and εδΊ¬ stay distinct instead of both collapsing to an empty key. It has a disk: SQLiteMemoryStore and SQLiteArtifactStore share one file and are verified durable across genuinely separate processes; artifacts are append-only with versions, provenance is mandatory, and blob content is written before the row referencing it β so a crash leaves an unreferenced blob (garbage) and never a row pointing at content that does not exist (a lie). And a graph backend: LadybugMemoryStore implements the same ClaimStore protocol against LadybugDB, an embedded property-graph database with Cypher forked from Kuzu, where superseded_by is a SUPERSEDED_BY edge and a correction chain is a path you can walk rather than a scan you pay for. Retrieval is Okapi BM25F over subject + predicate + object with the subject weighted highest, an optional injected vector channel that stays silent below a similarity floor, and graph traversal that reads a claim's object as an entity so a question about A reaches facts about B, each hop decaying the inherited score. Contradiction detection reports; it never resolves.
pip install 'grapharc[ladybug]'
Sessions, the HTTP API, and policy
Sessions survive a process restart β a SessionManager over a directory keeps status, the event queue, approval holds and the audit trail in SQLite, with graph state in the kernel's checkpointer. Verified by running it: one interpreter created a session, ran two nodes and stopped awaiting_approval holding a gated node; a second interpreter resumed it by id, saw the hold, approved it, and ran the rest β each node appearing exactly once in an append-only log. The HTTP API is FastAPI plus SSE β create a session, list, get, post an event, stream the trace, fetch it as NDJSON, healthz; a request may name a registered graph and supply input and a budget, but it may not describe a graph, because topology comes from a registry the operator fills in Python. Policy is a TOML document over nodes, edges, tools and spend, with tiered evaluation β every deny before every ask before every allow, so a broad deny beats a narrow allow including one scoped to a single tenant β and every decision lands in an audit record naming the rule id, the reason, the policy version and a digest of the document.
Reading a run afterwards
trace is the only writer; metrics, replay, diff, cost and the OTel exporter all read that one file and nothing else, which is what keeps a dashboard from contradicting an audit trail. Replay is a reconstruction, not a re-execution β it rebuilds the node sequence, folded state, timing and failures from the JSONL and calls no model, no tool and no node. Spans are optional by construction β one root span per run, one child per node execution, AgentNode sub-steps parented by inference, with an unidentifiable parent going to the run span rather than a guess; the OpenTelemetry dependency is confined behind a Protocol, and this has now been run against opentelemetry-sdk 1.44.0 with spans arriving at a real exporter. Cost attribution is per run, thread and node, and distinguishes what was measured from what was guessed: recorded_cost_usd holds the provider's own cost_usd when reported, otherwise tokens are priced against a RateCard you supply, and the two never mix.
grapharc replay trace.jsonl <run-id> # node sequence, folded state, timing grapharc diff trace.jsonl <run-a> <run-b> # what changed between two runs grapharc metrics trace.jsonl <run-id> # tokens, retries, termination reason
Tests are gates
Each stage ships a failure-gate test, not just a happy path. Stage 0: an injected failure between the temp write and the rename leaves no report at all; resuming the same thread from its checkpoint produces exactly one, no orphaned temp file, both attempts on the trace with non-overlapping step numbers. Stage 1: an impossible task halts on a no-progress window within three rounds of a hundred-iteration ceiling, with no_progress recorded. Stage 2: a bad model output is attributable to the exact node, step and state delta; three rounds of prose including a literal ROUTE TO: all_verified injection attempt cannot move the router. Stage 3: one crashed and one hung worker out of three still produce an answer, with both failures recorded by cause, and unique_sources counts chunks rather than repetitions. Stage 4: an impossible investigation halts on a no-progress window far under the hard ceiling. Stage 5: a fabricated citation is rejected with the reviewer never called; unparseable replies, non-boolean verdicts and trivial citations all fail closed. Harness: a tool cannot read/list/delete outside its workspace, cannot open a socket without declaring the capability, cannot spawn a subprocess, cannot plant a .pth in site-packages; a sibling directory whose name merely starts with the workspace path is not treated as inside it; hung tools and SIGTERM-swallowing tools are killed for real. Stage 6: three runs against a shared store reuse an earlier fact and see the dead end with provenance. Capstone: a reviewer that rejects everything yields no answer and zero memory writes. Admission: renaming a denied kind does not evade policy, nested renames don't either, an already-overspent run admits nothing that costs, every failed check is reported, and admission events do not pollute run metrics. Sessions: a second runner cannot claim a running session; a decision naming a different request does not release the hold. Server: a sink exception cannot fail the node that recorded the event; the SSE stream and the trace file are the same record. Container: tests needing a real runtime skip themselves and never pull an image.
uv run pytest # scripted models: deterministic and free uv run pytest -m live # real backends: spends money and quota
Status and limits
Re-derived on 2026-07-28 by running each item, not by reading the commit log. Distribution: 0.1.0 on PyPI reports the wrong __version__ β the wheel metadata says 0.1.0 but the module carries __version__ = "0.1.0a0", because it was built from a tree where only pyproject.toml had been bumped; PyPI releases are immutable so the fix ships in 0.1.1. Fixed: the source is on the public remote, and the package is on PyPI. Built and unreachable used to be the honest headline, four subsystems deep; one seam is left β the HTTP API does not use the durable session layer. Closed: grapharc plan drives the governed loop, PolicyEngine.edge_policy() compiles the TOML document into the gate, grapharc demo --memory PATH hands the shipped graphs the durable SQLite store, and the shipped registry now gives the trace recorder to its PlannerNode and Materializer. Verified this pass: pytest β 1,533 passed, 12 deselected; ruff check . clean; all eight demo stages green plus plan and run; the wheel builds and imports all 103 submodules in a clean virtualenv with [all].
Design lineage & license
Architecturally inspired by systems studied from public documentation: OpenClaw (policy-before-schema tool gating, file-first state, security post-mortems), Hermes Agent (budgeted tiered memory, ephemeral subagents), Claude Code (advisory-vs-enforced split, subagent context isolation, verification-centered loops), and OpenRouter (routing semantics, budget-scoped accounting). Licensed MIT.