UpsQuad LangGraph Runtime vs Claude Agent (SDK)
Audience: an engineer choosing (or governing) an agent runtime, or comparing UpsQuad's execution engine against Claude Code / the Claude Agent SDK. Goal: know exactly what each of our two worker runtimes does, what is real vs stubbed vs parked, and which UpsQuad governance layers wrap each.
Status legend: 🟢 LIVE (merged, in the prod code path) · 🟡 STUBBED (placeholder / permissive default) · 🔵 DEV-ONLY (wired for beta-dev/docker only) · 🟠 PARKED (designed and/or coded but gated off / blocked).
The headline that changed since the legacy doc: there is no longer a single "UpsQuad worker" to compare against Claude. UpsQuad ships two worker runtimes behind the same frozen gRPC worker contract and the runtime is chosen per agent at creation time (
langgraph|claude_sdk, ADR-0020, tracking #1631, plumbing PR-A #1634). The interesting comparison is now internal: LangGraph executor vs Claude SDK executor, plus how the Claude SDK stacks up as the upstream engine one of them wraps.
Changelog since 2026-07-06 (imported from Knowledge-Base Google Doc)
The legacy tab compared one "UpsQuad worker" against the Claude agent. Verified against main, the following material facts have changed:
- Per-agent runtime selection is live.
SessionSnapshot.runtimeselects the executor per session; the worker'sWORKER_EXECUTORenv is now only the fallback default. Precedence:snapshot.runtime → WORKER_EXECUTOR → "langgraph"(services/agent-worker/src/agent_worker/server.py:197-244).RegisterAgentvalidates and persistsruntime+llm_base_urltoagent_configurations(internal/agent/runtime_selection_test.go:22-63). - The Context-Engine client is NO LONGER a stub. The legacy doc's "our own Context Engine isn't wired to our own agents" is corrected:
context/client.pyis now a real Connect-over-HTTP caller of the Go Context Engine (#2030 / #2031, founder-approved 2026-07-23) — realAssembleContextRAG,IngestEvent, andValidatePrompt. But it is consumed only by the LangGraph graph, so the "ironic gap" inverts: LangGraph now gets real RAG; the Claude SDK path gets persona but NOT Context-Engine RAG. - Both runtimes are wired to the governed MCP team gateway (LLD #1855), replacing the "MCP stubbed by default" story — though the whole gateway ships feature-OFF (
MCP_GATEWAY_ENABLED=false, parked on #1864). - Persona confirmed for BOTH runtimes (snapshot field,
internal/runtime/session/snapshot.go:116,182). The Claude SDK path maps it straight tosystem_prompt.
1. The two executors behind one gRPC surface
Both runtimes implement the identical AgentExecutor Protocol (services/agent-worker/src/agent_worker/executor/interface.py:13-96) — initialize / execute_step / checkpoint / terminate (+ restore). The Go orchestrator, the relay, the audit chain, and the StreamEmitter are runtime-agnostic; the single execute_step call site in the servicer (server.py:329) does not know which engine it drove.
Selection & routing (verified):
snapshot.runtime(whitelisted to{langgraph, claude_sdk}) wins; otherwise the worker default (server.py:197-221). The chosen name is recorded persession_idso the snapshot-less RPCs (Terminate/Checkpoint) route to the same backend that owns the session (server.py:451,480).- The default executor is built eagerly; any alternate runtime is built lazily and cached in
self._alt_executors(server.py:223-244). - The runtime choice is immutable per session and part of the snapshot integrity hash — but
"langgraph"is normalised to empty so default agents hash byte-identically to pre-#1631 (snapshot.go:166-173).
2. Capability comparison (dimension × runtime)
| Capability | LangGraph runtime (default) | Claude SDK runtime (claude_sdk) | Notes |
|---|---|---|---|
| Positioning (ADR-0020) | Production / regulated agents | Dev / internal use | Per-agent choice; both governed |
| Core agentic loop | 🟢 Explicit Go/Python graph: assemble→validate→call_llm→parse→execute_tools→ingest, bounded loops | 🟢 SDK owns its internal think-act loop within one execute_step; we do not drive per-cycle | LangGraph loop is ours; SDK loop is Anthropic's |
| Autonomous planning / termination | 🟢 Go-side PlanLoop, forced submit_plan decomposition (≤32 todos), authoritative terminator | 🟡 SDK plans internally; Go terminator demoted to a budget + HITL guard (ADR-0020 trade-off #2) — forced-submit_plan determinism lost | External enforcement is stronger on LangGraph |
| Built-in tools (files/shell/web) | ❌ None — no local tool suite | 🟡 Present but hard-denied by default (tools=[] + disallowed_tools); per-agent builtin:* sentinel enables them in an isolated scratch cwd, still gated | claude_sdk_executor.py:88-103,365-467 |
| MCP tool execution | 🟢 Wired to governed team gateway via agent_gateway_token in graph state (LLD #1855 PR-2); local mcp_stub_mode still defaults True for the non-gateway path | 🟢 ONE SDK MCP server config → {base}/mcp/team/{unit} (Bearer), one mcp__<server>__* allow-glob per allowed server (PR-3) | Whole gateway ships feature-OFF (#1864) |
| Persona / system prompt | 🟢 snapshot.persona folded into graph assembly | 🟢 snapshot.persona → system_prompt directly (claude_sdk_executor.py:412,488) | Both receive persona |
| Context assembly (RAG) | 🟢 Real Context-Engine AssembleContext via context_engine_token (#2030/#2031) — fail-safe to empty on CE outage | 🟠 Does NOT call the Context Engine — RAG assembly is a LangGraph-graph node; the param is accepted only for interface parity (interface.py:70-74, claude_sdk_executor.py:239) | Inverted gap vs legacy doc |
| Prompt guardrail (ValidatePrompt) | 🟢 Real verdict from CE; fails OPEN + integrity_check_skipped audit on outage (advisory tier) | ❌ No ValidatePrompt call (no CE client) | Deterministic tier is ToolPolicy at the MCP edge, not this call |
| Conversation persistence | ⚠️ In-memory + config-snapshot checkpoint only; messages not persisted (parked, compliance) | ⚠️ SDK owns conversation state internally; worker persists only the config snapshot (stateless-worker tenet) | Neither persists transcripts |
| Checkpointing + HITL | 🟢 Versioned checkpoint blobs (OQ-5 header, keys stripped), in-step approval suspend → interrupt_required, idempotency-keyed resume | 🟢 canUseTool denies every tool reaching a permission decision → same interrupt_required shape; config-snapshot checkpoint + restore | SDK conversation state is not our checkpoint; only config is |
| Audit trail | 🟢 Graph nodes emit tool_call/tool_result → hash-chained Go audit | 🟢 PreToolUse/PostToolUse hooks → same event dicts → same audit chain | Parity via the shared emitter |
| Token streaming | ⚠️ Buffered — graph runs compiled.ainvoke, events replayed after completion | 🟢 SDK query() pushes AssistantMessage text blocks onto a bridge queue → token events interleaved as they arrive | SDK is the more incremental of the two |
| Model flexibility | 🟢 BYOK anthropic/openai/google + keyless local Ollama | ❌ Anthropic-direct only (provider_keys['anthropic']); optional per-session ANTHROPIC_BASE_URL | ADR-0020 trade-off #1 |
| Stateless-worker tenet | 🟢 Holds (all state externalised; #1622 lazy re-init) | 🟡 Scoped exception — one stateful SDK session per active session (ADR-0020 trade-off #3) | Config snapshot still stateless; SDK conversation state is not |
| Multi-tenancy / clearance / audit | 🟢 Sacred tenant scoping, clearance, org-unit RBAC | 🟢 Same governance envelope (both wrap the same gRPC contract + gateway) | Our moat, preserved for both |
| Core-path dependency | 🟢 None new | 🟠 claude-agent-sdk optional extra; a mis-built image surfaces a clean SDK_UNAVAILABLE (#1636) | Supply-chain / version-churn risk |
3. Where the two paths diverge (the load-bearing details)
3.1 Persona vs Context Engine — the corrected picture
- Both runtimes receive
personain the snapshot (snapshot.go:116,182). - Only LangGraph dials the Context Engine. Its
execute_steprefreshesstate["context_engine_token"]each step (langgraph_executor.py:426); the graph's assemble node callsContextEngineClient.assemble_context(real RAG) andvalidate_prompt(advisory, fail-open). The client degrades gracefully — an unreachable CE returns empty context + WARN and never crashes the turn (context/client.py:26-30,180-181). - The Claude SDK path deliberately does not construct or call
ContextEngineClient;context_engine_tokenis accepted purely for interface parity (claude_sdk_executor.py:239). Its "context" is the persona system-prompt plus whatever the SDK manages internally. So aclaude_sdkagent is knowledge-blind to our RAG today — the follow-up seam is documented but unwired.
3.2 Governance on the Claude SDK path (how the moats survive)
The SDK is powerful, so governance is layered on top (claude_sdk_executor.py:11-38):
- Built-ins disabled by default —
tools=[]strips the base set;disallowed_toolsre-blocks Bash/Read/Write/Edit/… as defence-in-depth. Only governedmcp__<server>__*globs may auto-run. canUseTool→ deny →interrupt_required— any tool call reaching a permission decision becomes a HITL gate routed through the Goapproval.Service, mirroring the LangGraph in-step interrupt (T4-7).PreToolUse/PostToolUsehooks →tool_call/tool_result— every call is observed and flows to the Go audit chain via the existing relay.strict_mcp_config=True— the governed team gateway is the ONLY reachable MCP surface (no ambient.mcp.json).
3.3 Termination authority
LangGraph keeps the Go PlanLoop as the authoritative terminator (budget/stall/goal axes, forced submit_plan). For claude_sdk, the SDK owns its own loop, so the Go terminator is demoted to a budget + HITL guard — an explicit, accepted trade-off for that worker type only (ADR-0020).
4. Component & status map
| Component | Path | Status | Notes |
|---|---|---|---|
AgentExecutor protocol | executor/interface.py | 🟢 LIVE | Single contract both runtimes satisfy |
| LangGraph executor | executor/langgraph_executor.py | 🟢 LIVE | Default; graph loop, real CE, forced submit_plan |
| Claude SDK executor | executor/claude_sdk_executor.py | 🟢 LIVE (code) | Wraps claude-agent-sdk; merge was ADR-gated (#1626/#1627) |
| Per-session executor routing | server.py:197-244 | 🟢 LIVE | snapshot.runtime → default; records session→executor |
| Runtime selection + persistence | internal/agent (runtime_selection_test.go), agent_configurations.runtime/llm_base_url | 🟢 LIVE | Validated in RegisterAgent; mig 123/158 |
Snapshot runtime/llm_base_url (integrity-hashed) | internal/runtime/session/snapshot.go:131-190 | 🟢 LIVE | langgraph normalised to empty |
| Context Engine client (real) | context/client.py | 🟢 LIVE | Connect-over-HTTP (#2030/#2031); LangGraph-only consumer |
| Claude SDK → Context Engine | — | 🟠 UNWIRED | Seam accepted for parity; not called |
| MCP over governed gateway (both) | LLD #1855 PR-2/PR-3 | 🟢 LIVE (code) · 🟠 prod-OFF | MCP_GATEWAY_ENABLED=false, #1864 |
| LangGraph local MCP (non-gateway) | mcp_stub_mode | 🟡 STUBBED | Defaults True |
| Claude SDK built-in tools | claude_sdk_builtin_tools / builtin:* sentinel | 🔵 DEV-ONLY | Default OFF (hard-deny); ON = sandboxed scratch cwd |
SDK_UNAVAILABLE guard | claude_sdk_executor.py:61-80,271-283 | 🟢 LIVE | Clean error on mis-built image (#1636) |
5. When to pick which
- Pick LangGraph (default) for production, regulated, or multi-provider agents: authoritative Go termination, forced-
submit_plandeterminism, real Context-Engine RAG + advisory prompt validation, BYOK across anthropic/openai/google + local Ollama, full stateless-worker tenet. - Pick Claude SDK for dev / internal agents that want the full Claude-Code capability surface (rich built-in tools, SDK-managed context, more incremental streaming) — accepting Anthropic-only models, a demoted terminator, no Context-Engine RAG, and a scoped stateful-session exception.
Both run behind the same frozen gRPC contract and the same governance envelope (tenant isolation, clearance, hash-chained audit, governed-MCP edge), so switching runtime is a single per-agent field — not a re-platforming.
6. Related design records
- ADR-0020 — Dual-runtime: Claude Agent SDK as a second worker type behind the frozen gRPC contract (Accepted 2026-07-02, #1627). Resolves ADR-0012's parked runtime-vs-middleware question with "both, per-agent choice".
- ADR-0019 — Adopt-and-wrap autonomous runtime (the LangGraph worker this keeps as default).
- #1631 (tracking) / #1634 (PR-A plumbing) — per-agent runtime selection.
- LLD #1855 — governed-MCP wiring for both runtimes; #1864 — trust-root blocker (prod gateway parked).
- #2030 / #2031 — real Context-Engine client (LangGraph); #1626 / #1636 — Claude SDK spike + image-build guard.
- Governed-MCP edge model: see Governed MCP & the Team Gateway (ADR-0024/0025).
Reflects upsquad-core@9858af5f (2026-07-24). If ADR-0020 trade-offs change, the Claude SDK path gains a Context-Engine call, or MCP_GATEWAY_ENABLED flips, update §2 and §3.