Build an application-ready, SDK-first agent runtime wrapper that:
- Exposes one stable contract for: sessions, messages, events, tool calls, artifacts, cancellation, and traceability.
- Wraps agent runtimes (not “model providers”) behind unique runtime adapters (no “false unification” of tool semantics).
- Makes tool execution owned (permissioning + sandboxing + auditing) instead of delegating to opaque CLIs.
This intentionally mirrors the strongest parts of Agor’s methodology:
- A small capability-driven interface (e.g.,
supportsStreaming,supportsResume,supportsToolCalls). - Per-runtime “prompt service” loops that stream runtime-native events and emit normalized events.
- A normalization layer that preserves
rawpayloads as an escape hatch for debugging.
- A production Web UI (a dev console/CLI harness is fine).
- Supporting “every runtime”. Start with: Claude, Codex, Gemini, OpenCode.
- Perfect feature parity across runtimes (capabilities + graceful degradation instead).
- A single API/SDK surface an app can use to:
- create/resume a session,
- stream tokens/blocks/events,
- receive tool call requests,
- approve/deny/execute tools in a sandbox,
- persist and replay the full event log for debugging.
- A daemon (
codesdkd) suitable for embedding as a subprocess, exposing the same contract over HTTP + SSE for apps that prefer a process boundary. - Deterministic local test suite (mocks/fixtures) + optional live tests.
- “Support bundle” export that makes high-level debugging possible without SSH.
TypeScript (Node) is the pragmatic choice if you want “Agor-like” SDK integrations:
- Claude:
@anthropic-ai/claude-agent-sdk - Codex:
@openai/codex-sdk - Gemini:
@google/gemini-cli-core - OpenCode:
@opencode-ai/sdk(talks toopencode serve)
If you choose Python, you’ll likely end up implementing to raw HTTP APIs instead of these agent SDKs.
Responsible for the stable “family” of types and guarantees:
- IDs:
session_id,task_id,event_seq(monotonic per session),request_id,client_id. - Events (append-only): normalized event types with timestamps + trace context.
- Messages: canonical transcript representation (text, tool_use, tool_result, attachments).
- Tool calls: request/response schema + registry metadata (JSON schema / zod).
- Capabilities: runtime feature flags + versioning (plus auth/tool/permission execution models).
- Error model: structured errors with
code,retryable,runtime,raw.
Owns orchestration and consistency:
- Session lifecycle: create/resume/stop.
- Task lifecycle: compile context → stream → tool loop → completion.
- Backpressure: bounded queues per session, per runtime.
- Cancellation: propagated to runtime (abort/interrupt/stop).
- Persistence hooks: every emitted event goes to the event store.
Each runtime adapter implements a minimal interface such as:
getAuthStatus()(OAuth implies local state; first-class, safe metadata only)checkAvailable()(runtime readiness)createSession()(if supported)startTask()→ a duplex task handle (compiled messages in; events out; tool results/denials in)task.stop()(best-effort cancellation; runtime-specific)normalizeRawResponse()(optional; emit raw in events anyway)
OAuth and runtime-managed credential caches are a core driver, so model them explicitly:
- Query auth status per runtime (logged in? which mode?).
- Provide dev-friendly
ensureLogin()instructions without hard-coding UI. - Enforce “credential namespace” boundaries for multi-tenant setups.
Agent runtimes read/write local state (HOME/XDG/config/policies/logs). Treat this as part of the system contract:
- Support shared vs namespaced vs ephemeral runtime environments.
- Make “where runtime state lives” explicit via
credentialNamespace. - Enable deterministic testing by pinning runtime env + settings sources.
- Explicitly model how the runtime is hosted for isolation:
in_process|subprocess|server_side.- Multi-tenant deployments should avoid
in_processfor runtimes that use disk-based OAuth caches (prefersubprocessorserver_side).
- Multi-tenant deployments should avoid
- For
subprocess, standardize an IPC protocol that preserves duplex semantics (recommendation: JSON-RPC 2.0 over stdio with event notifications).
Tool execution should not be runtime-owned by default. CodeSDK should:
- Receive tool call requests from runtime adapters (or via MCP).
- Apply permissioning policy and emit policy/audit events.
- Execute tools in a sandbox (see below).
- Feed tool results back to the runtime loop.
Principle: MCP-first tool ownership. Runtime-native tools are treated as legacy/internal and must be explicitly enabled.
Minimum viable storage:
- Append-only event log per session (
seq,ts,type,payload,trace). - Artifacts store (files/blobs) with content type and stable download semantics.
- Optional: snapshot tables for fast list views (sessions/messages).
- Design rule: normalized event rows stay small; large
rawpayloads are blobbed as artifacts and referenced (e.g.,raw_ref).
For “daemon-first” integrations, CodeSDK provides codesdkd:
- HTTP endpoints for session/task lifecycle and artifact download.
- SSE streaming for normalized events.
- Durable default stores via a single
--data-dirroot (SQLite events + file artifacts + runtime-env dirs). - Tool approval endpoints when
permissionMode=ask(pause tool execution until approve/deny).
See docs/daemon-api.md.
All runtime streams map into a small set, with a raw escape hatch:
session.createdtask.startedmodel.input(canonical runtime request; prefer artifact/blob refs for large payloads)runtime.request.started/runtime.request.completed(optional but useful)model.output.delta(typed block delta;payload.kinddistinguishestext_deltavsjson_deltavscode_delta)model.output.completed(final assistant blocks; stable, typed blocks suitable for transcript)tool.call.requested(name + input schema instance)tool.call.policy_evaluated(may occur multiple times;{source, result, rule?}whereresult ∈ {allow, deny, ask})tool.call.approved/tool.call.deniedtool.call.started/tool.call.completedtool.output.delta/tool.output.completed(stdout/stderr/progress streaming)usage.reported(tokens + timings, when available)task.completedtask.failed(structured error)task.stopped
Every event includes:
schema_version(event schema version; bump on breaking payload changes)seq(monotonic per session)time(ISO)trace:{ client_id?, request_id?, user_id?, session_id, task_id? }runtime:{ name, model?, runtime_session_id?, raw? }
Notes:
tool.call.policy_evaluatedcan occur multiple times (runtime policy engine, CodeSDK policy, user decision).tool.call.approved/tool.call.deniedrepresent the final effective decision (single terminal decision per tool call).
Define task + tool-call attempt states explicitly (these are engine states, not necessarily emitted as events):
Task states:
running: streaming model output and/or waiting for runtime events- terminal:
completed | failed | stopped
Tool-call attempt states (for tool_call_id + attempt):
waiting_for_tool_decision:tool.call.requestedemitted; awaiting approve/denyrunning_tool: tool is executing (optionaltool.output.*streaming)feeding_tool_result: tool result/denial is being sent back into the runtime loop- terminal:
completed | denied
Core invariants (this prevents most race-condition bugs):
- Exactly one terminal event per task:
task.completed | task.failed | task.stopped. - For a given
task_id,task.startedis the first task-scoped event and the terminal event is the last. - One active task per
session_id(P0 default): the executor enforces a per-session mutex; clients must not assume task concurrency within a session. - Event sequencing:
seqis assigned monotonically per session and must be persisted to the event store before the event is emitted/streamed to clients. - Parallel tool calls: a runtime may request multiple tools “at once”. The executor must model tool-call attempt state per
tool_call_id + attemptand allow multiple outstanding tool calls per task (at minimum: queue + execute serially in P1). - Tool call graph integrity: for each tool call attempt (
tool_call_id + attempt):tool.call.requestedmust exist before any other tool events for that attempt.tool.call.approved | tool.call.deniedoccurs exactly once as the terminal decision for that attempt.- If approved:
tool.call.startedandtool.call.completedmust occur exactly once (in that order). - If denied: no tool execution events occur; CodeSDK must still return a deterministic denial back to the runtime loop via
sendToolDenied(...).
schema_versionis on every event and bumps on breaking changes to event payload shapes/semantics.session.createdshould include acontract_versionso support bundles and fixtures can be interpreted against the intended contract.- Migration strategy: write readers as “upcasters” (old → latest in-memory shape) rather than rewriting historical logs.
Problem: runtime-native session/thread state is not reliable SSOT, but many runtimes depend on it for “context”. CodeSDK must own a deterministic, replayable transcript and a deterministic way to compile runtime inputs.
Design rules:
- Treat “context” as data:
{messages, tool_manifest, runtime_config, attachments}. - Never require re-running tools to resume: tool outputs must be persistable and replayable.
- Keep event rows small: store large compiled inputs/outputs as artifacts and reference them.
Implicit prompt/context sources (“hidden input” problem)
Many runtimes inject context you didn’t explicitly provide (project instruction files, user config/policy prompts, runtime defaults, internal tool manifests).
Per runtime, CodeSDK must choose and document one of:
- Disable implicit sources (best for determinism), or
- Capture and include them (best for “matches native runtime behavior”).
Regardless of choice, model.input must make the effective behavior observable:
- include an
implicit_sources_refartifact that lists sources, their precedence, and safe hashes (never secrets) - if sources are disabled, record
{ disabled: true, reason }in that artifact
- Messages have a
role ∈ {system, user, assistant, tool}andcontent[]blocks. - Content blocks must be able to represent:
- text
- tool call requests (assistant
tool_usewith stabletool_call_id) - tool results (tool
tool_resultwith the sametool_call_id) - artifact/attachment refs (files, images, large tool output bodies)
To avoid duplication (especially when a runtime input includes the full history), derive the transcript from “delta” sources:
task.started: the canonical user message(s) for the task (not the full compiled prompt).tool.call.requested: becomes an assistanttool_usemessage block.tool.call.completed: becomes a tooltool_resultmessage block (prefer artifact refs for large outputs).tool.call.denied: becomes a tooltool_resultmessage block withis_error: true(include the denial reason sent back to the runtime).model.output.completed: becomes the assistant message block(s) for the task.
For every runtime request, compile a RuntimeInput (shape varies per runtime) that includes at least:
- compiled
messages[](full or delta, but recorded) tool_manifest(names + schema hashes for the tools that were exposed)runtime_config(model + any explicit runtime options)context_windowmetadata (strategy, truncation, omitted ids)
Store the full compiled input as an artifact and reference it from the model.input event via input_ref + input_hash.
Large tool outputs and long sessions will overflow context windows.
Minimum viable strategy (P1):
- Replace large tool outputs with
{artifact_ref + short preview}first. - If still too large: drop oldest turns and record what was omitted in
model.input.context_window. - If the runtime rejects input anyway: fail with a structured
task.failed(code: CONTEXT_TOO_LARGE) and surface a “summarize/compact” recovery instruction.
Summarization/compaction can come later (P3+), but the budgeting + omission reporting must exist early for debugging.
Expose a structured capability object (Agor-style) so UIs/clients don’t guess:
supportsStreamingsupportsToolCallssupportsParallelToolCallssupportsSessionCreatesupportsSessionResumesupportsStopsupportsArtifactssupportsUsageReportingsupportsNonInteractivemaxOutstandingToolCalls(number)
Add “runtime wrapper reality” fields so clients don’t make unsafe assumptions:
authModel:oauth_local|api_key|bothtoolExecutionModel:external_mcp|runtime_internal|hybridpermissionModel:codesdk|runtime|hybridstateModel:in_process|local_disk|server_side|hybridresumeModel:native|reconstruct|nonetoolReplaySafety:safe_replay|requires_reapproval|unknownmcpSupport:none|client_only|server_only|bothmcpTransports:stdio|sse|http(list of supported transports)cancellationModel:best_effort|guaranteed|unknownsupportedIsolationModes:in_process|subprocess|server_side(list)
Define a small canonical set and map runtime-specific modes into it:
ask(always prompt before tools/edits)auto(auto-approve safe tools)yolo(auto-approve everything; strongly gated)
Runtimes that have their own modes should map, not redefine the contract.
Wrapping agent runtimes introduces multiple “resume” forms:
- Native resume: the runtime has a durable session/thread id that can truly continue.
- Reconstruct resume: CodeSDK rebuilds runtime context from its SSOT transcript/event log.
- None: no meaningful resume beyond starting fresh.
Design rules:
- Treat runtime-native ids (
runtime_session_id,thread_id, etc.) as optimizations, not SSOT. - Make tool execution resilient to retries/resume: tool call identity must be stable and auditable.
Minimum tool-call identity fields (included on all tool call events):
tool_call_id(CodeSDK stable id)runtime_tool_call_id(optional; if runtime provides one)attempt(starts at 1; increments on replay/resume)input_hash(hash of canonicalized input)
This enables “exactly-once-ish” behavior for safe tools and “at-most-once unless re-approved” behavior for risky tools.
Idempotency and replay depend on hashes being stable.
Minimum requirements:
- Algorithm: SHA-256 (encode as
sha256:<hex>). - Canonical JSON: use a deterministic canonicalization scheme (recommendation: RFC 8785 JCS) so key ordering and number formatting can’t drift across runtimes/languages.
- Artifact refs: represent large inputs/outputs by stable
{artifact_id, content_hash?}objects; hashes must never depend on local file paths.
What to hash:
ToolCallIdentity.input_hash: hash the canonical JSON form of the tool call input (including artifact refs, not artifact bytes).model.input.input_hash: hash the canonical JSON form of the compiled runtime input (compiled messages + tool manifest schema hashes + explicit runtime config + context_window omissions).
Default design: CodeSDK hosts tools via MCP and runtimes call those tools via MCP clients whenever supported.
Benefits:
- consistent permissioning + auditing + sandboxing
- avoids fighting each runtime’s internal tool plumbing
- makes “owned tools” compatible with keeping runtime OAuth ergonomics
Implement a registry with:
- tool name (namespaced):
workspace.read,workspace.write,patch.apply,http.fetch(optional) - JSON schema for inputs/outputs
- default permission classification:
read-only,write,network,dangerous
Default policy:
workspace.execis disabled by default.- If enabled, require Docker isolation + strict allowlist + timeout + network off by default.
Permission decisions should be first-class events:
- record who approved (user/system), when, and why
- record the effective policy (session permission mode + overrides)
Support bundles are only actionable if tool execution is attributable.
Minimum required fields on tool.call.completed payload (in addition to tool identity):
name(tool name)executed_by:codesdk | runtime(who actually ran it)execution_env: e.g.codesdk_docker | codesdk_host | runtime_internal | unknownpolicy_snapshot: effective{ permission_mode, decision: allow, sources[] }that led to executionsandbox: safe summary{ network: boolean, timeout_ms?: number, mounts?: string[] }result_ref(artifact ref for large outputs) and/or a smallresult_preview
If a runtime executes tools internally, adapters must still emit these fields with executed_by: runtime so auditing and replay stay coherent.
For denied tool calls, tool.call.denied should include a safe reason plus the same policy_snapshot shape (with decision: deny) so the transcript and support bundles explain why the runtime continued without the tool.
For robust multi-tenant operation, run tools in Docker (or another container runtime):
- Per-session workspace mounted read/write as configured
- Network disabled by default; selectively enable per tool/policy
- CPU/memory/time limits
- Allowlist commands for
workspace.exec(or removeexecentirely and use higher-level tools)
This isolates tool execution even if a runtime behaves unexpectedly.
In addition to implementing the adapter interface, each runtime needs a short operational spec (SSOT) describing harness quirks and default choices:
docs/runtimes/claude-agent-sdk.mddocs/runtimes/codex-sdk.mddocs/runtimes/gemini-cli-core.mddocs/runtimes/opencode-server.md
Each spec must include:
- auth state locations + how
runtime-envisolates them - implicit prompt/context sources (disable vs capture, and how reflected in
model.input) - tool restriction strategy (don’t trust allowlists without enforcement hooks)
- parallel tool call behavior + default config
- MCP transport preference + fallback order
- cancellation semantics + known failure modes
- runtime version pinning + config snapshot included in support bundles
- Use hooks (e.g.,
canUseTool) for permission gating and event emission. - Prefer MCP-first: expose CodeSDK tools via an MCP server and only allow those tools by default.
- Resume: store
runtime_session_idin session metadata when available. - Stop: call SDK interrupt / abort where supported.
- Emit: tool call request events before execution; include raw SDK event payloads.
- Implement thread/session continuity per SDK primitives; treat thread id as an optimization, not SSOT.
- Prefer MCP-first tools; layer any runtime exec-policy constraints as defense-in-depth.
- Stop: SDK-specific cancellation flag (or abort controller if exposed).
- Support both auth modes (surface via
getAuthStatus()):- API key (
GEMINI_API_KEY) - OAuth creds (via
gemini loginon the host; store per-user path if multi-tenant)
- API key (
- Use
sendMessageStream()and map SDK event types to SSOT events. - Tool calls: emit
tool.call.policy_evaluatedwhen runtime policy engine decides (ask/auto/yolo mapping). - Prefer MCP-first tool discovery for “owned tools”.
- Treat OpenCode as a server runtime: CodeSDK connects to
opencode serve. - Support provider+model config explicitly (OpenCode has two dimensions: provider + model).
- Streaming: SSE events mapped to SSOT events.
- Prefer MCP-first external tools where supported; be explicit about transport limitations.
- Normalizers: golden fixtures per runtime → SSOT events.
- Permission mapping: canonical modes → runtime modes.
- Event store: sequencing, paging, replay.
- Tool registry validation: schema enforcement.
- Mock runtime SDK clients to simulate:
- streaming deltas
- tool call requests
- failures/timeouts
- cancellation
- Assert the engine produces the correct SSOT event order.
- Only run when env vars are present (e.g.,
RUN_LIVE_TESTS=1and runtime credentials). - Focus on smoke-level scenarios (one prompt, one tool call).
- Record representative raw SDK events (redacted) and commit as fixtures.
- Provide a “replayer” that runs the engine against fixtures to validate determinism.
Every runtime adapter must pass the same conformance matrix (with mocked SDKs; optionally gated live runs). Full matrix spec: docs/contract-tests.md.
- streaming:
model.output.delta→model.output.completed(typed blocks, stableblock_id) - tool loop (approve):
tool.call.requested→ approve → execute →tool.call.completed(with execution metadata) →sendToolResult(...) - tool loop (deny):
tool.call.requested→ deny →tool.call.denied(with reason + policy_snapshot) →sendToolDenied(...) - cancellation:
stop()mid-stream → terminaltask.stoppedand no further tool execution - non-interactive mode: runtime must not block on prompts; “ask” policy must degrade deterministically
- implicit sources:
model.inputincludesimplicit_sources_refand hashes (or records explicit disablement) - parallel tool calls: either handled (multiple outstanding tool_call_id) or explicitly disabled and asserted by config/capability
- tool restriction: forbidden tools must not execute (test behavior, not just “tool list”)
- MCP transport: adapter uses declared transport preference order; record chosen transport in support bundle
Implement an audit stream (global + per-session):
- inbound requests (create session, prompt, tool decision)
- tool execution start/end (including sandbox parameters)
- runtime errors (rate limits, auth failures)
- storage failures
A single export artifact should include:
/health+/capabilitiessnapshots- runtime versions + adapter versions
- runtime config/policy snapshot (paths + safe hashes + redaction, no secrets)
- MCP transport matrix snapshot (supported + chosen transport per runtime)
- session event log (paged)
- tool execution transcripts (stdout/stderr, exit codes)
- redacted runtime metadata (model, request IDs)
Expose Prometheus metrics for:
- active sessions/tasks
- runtime latency distributions
- tool execution latency/failures
- queue depth/backpressure drops
One workable layout:
CodeSDK/
packages/
core/
src/
types/
events/
tools/
tracing/
executor/
src/
engine/
storage/
auth/
runtime-env/
runtimes/
claude-agent-sdk/
codex-sdk/
gemini-cli-core/
opencode-server/
normalizers/
If you prefer a single-package repo initially, mirror the same folder structure under src/.
- Choose TS toolchain (pnpm + tsup/tsc + vitest) and set up CI.
- Define SSOT types:
Session,Task,Event,ToolCall,Artifact,Capabilities. - Add event schema versioning (
schema_version+contract_version) and a reader migration note. - Define executor task state machine + invariants (terminality, tool-call ordering, per-session mutex).
- Define
model.output.delta/model.output.completedtyped block payloads. - Define canonicalization + hashing for
input_hash(stable JSON + SHA-256). - Define required tool execution metadata fields for
tool.call.completed. - Define runtime adapter interface as duplex (task handle with channels).
- Define capabilities model (include auth/tool/permission/state/resume/isolation models).
- Implement event sequencing + in-memory event store + paging.
- Create a tiny CLI harness that can run one session and print streamed events.
- Implement
auth/+runtime-env/primitives (credential namespace + isolation level). - Implement a minimal in-process MCP tool host (1–2 tools, e.g.
workspace.read). - Implement one runtime end-to-end:
- streaming → SSOT deltas/completed
- policy/permission events (including
tool.call.policy_evaluated) - at least one tool call executed via CodeSDK via MCP (prove the “owned tools” path)
- Add runtime-level “health/auth check” surfaces.
- Add a second runtime adapter and prove the same end-to-end behavior.
- Add fixtures for both runtimes and validate deterministic replay.
- Expand MCP tool host +
ToolRegistry+PermissionService. - Implement tool call request/approval/denial events (even when runtime policy engine decides).
- Wire tool results back into runtime adapters (runtime-specific reply format).
- Add Docker sandbox runner for tools (configurable; default off).
- Add durable event store (SQLite first; optional Postgres).
- Add artifacts store with stable download semantics.
- Add audit log + support bundle exporter.
- Add Prometheus metrics + structured logging.
- Add load/concurrency limits + rate limiting per runtime.
- Library-first: publish CodeSDK as a package for other services to embed.
- Daemon-first: ship
codesdkdHTTP API (can mimic CLIwrapper’s API spec). - Provide reference “dev console” for manual testing (not a product UI).
- Single-tenant (one operator) or multi-tenant (per-user creds + isolation)?
- Is Docker available/required for tool sandboxing, or is host execution acceptable?
- Should we standardize tool calls on MCP as the primary tool transport?
- Do we need strict API compatibility with CLIwrapper, or can this be a new contract?