Skip to main content

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:

  1. Per-agent runtime selection is live. SessionSnapshot.runtime selects the executor per session; the worker's WORKER_EXECUTOR env is now only the fallback default. Precedence: snapshot.runtime → WORKER_EXECUTOR → "langgraph" (services/agent-worker/src/agent_worker/server.py:197-244). RegisterAgent validates and persists runtime + llm_base_url to agent_configurations (internal/agent/runtime_selection_test.go:22-63).
  2. 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.py is now a real Connect-over-HTTP caller of the Go Context Engine (#2030 / #2031, founder-approved 2026-07-23) — real AssembleContext RAG, IngestEvent, and ValidatePrompt. 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.
  3. 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).
  4. Persona confirmed for BOTH runtimes (snapshot field, internal/runtime/session/snapshot.go:116,182). The Claude SDK path maps it straight to system_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 per session_id so 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)

CapabilityLangGraph runtime (default)Claude SDK runtime (claude_sdk)Notes
Positioning (ADR-0020)Production / regulated agentsDev / internal usePer-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-cycleLangGraph 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 lostExternal 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 gatedclaude_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.personasystem_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 + restoreSDK 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 chainParity 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 arriveSDK is the more incremental of the two
Model flexibility🟢 BYOK anthropic/openai/google + keyless local OllamaAnthropic-direct only (provider_keys['anthropic']); optional per-session ANTHROPIC_BASE_URLADR-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 persona in the snapshot (snapshot.go:116,182).
  • Only LangGraph dials the Context Engine. Its execute_step refreshes state["context_engine_token"] each step (langgraph_executor.py:426); the graph's assemble node calls ContextEngineClient.assemble_context (real RAG) and validate_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_token is accepted purely for interface parity (claude_sdk_executor.py:239). Its "context" is the persona system-prompt plus whatever the SDK manages internally. So a claude_sdk agent 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 defaulttools=[] strips the base set; disallowed_tools re-blocks Bash/Read/Write/Edit/… as defence-in-depth. Only governed mcp__<server>__* globs may auto-run.
  • canUseTool → deny → interrupt_required — any tool call reaching a permission decision becomes a HITL gate routed through the Go approval.Service, mirroring the LangGraph in-step interrupt (T4-7).
  • PreToolUse/PostToolUse hooks → 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

ComponentPathStatusNotes
AgentExecutor protocolexecutor/interface.py🟢 LIVESingle contract both runtimes satisfy
LangGraph executorexecutor/langgraph_executor.py🟢 LIVEDefault; graph loop, real CE, forced submit_plan
Claude SDK executorexecutor/claude_sdk_executor.py🟢 LIVE (code)Wraps claude-agent-sdk; merge was ADR-gated (#1626/#1627)
Per-session executor routingserver.py:197-244🟢 LIVEsnapshot.runtime → default; records session→executor
Runtime selection + persistenceinternal/agent (runtime_selection_test.go), agent_configurations.runtime/llm_base_url🟢 LIVEValidated in RegisterAgent; mig 123/158
Snapshot runtime/llm_base_url (integrity-hashed)internal/runtime/session/snapshot.go:131-190🟢 LIVElanggraph normalised to empty
Context Engine client (real)context/client.py🟢 LIVEConnect-over-HTTP (#2030/#2031); LangGraph-only consumer
Claude SDK → Context Engine🟠 UNWIREDSeam accepted for parity; not called
MCP over governed gateway (both)LLD #1855 PR-2/PR-3🟢 LIVE (code) · 🟠 prod-OFFMCP_GATEWAY_ENABLED=false, #1864
LangGraph local MCP (non-gateway)mcp_stub_mode🟡 STUBBEDDefaults True
Claude SDK built-in toolsclaude_sdk_builtin_tools / builtin:* sentinel🔵 DEV-ONLYDefault OFF (hard-deny); ON = sandboxed scratch cwd
SDK_UNAVAILABLE guardclaude_sdk_executor.py:61-80,271-283🟢 LIVEClean 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_plan determinism, 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.


  • 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.