@qodo/sdk
v2.2.1
Published
Qodo SDK 2.0 — QAR-native typed bindings for the Qodo Agent Runtime
Readme
@qodo/sdk
Official TypeScript SDK for building AI-powered agents with Qodo.
Migrating from 1.x? The full 1.x → 2.0 upgrade guide is at
docs/sdk2/migration-from-1x.md, with paired side-by-side examples inexamples/migration-rosetta/.1.x users (legacy line, published as
0.13.x): SDK 2.0 targets the Qodo Agent Runtime (QAR). The legacy line continues on therelease/1.xbranch with security and critical-bug patches only, on a calendar tied to command-backend's Sept 2026 EOL.
Installation
# 2.0 (stable — QAR-native)
npm install @qodo/sdk
# 1.x (legacy, command-backend-native — published as 0.13.x)
npm install @qodo/[email protected]@qodo/sdk@latest now resolves to 2.0. Existing legacy applications should keep their 0.13.x pin until they complete the migration above.
Requirements: Node.js >= 20, ESM only
WebSocket connections behind a proxy
The Node default transport automatically uses HTTP/HTTPS CONNECT proxies from
HTTPS_PROXY for wss:// and HTTP_PROXY for ws://, with ALL_PROXY as a
fallback. Lower-case forms are supported and take precedence. NO_PROXY
(or no_proxy) bypasses matching hosts, domain suffixes and optional ports;
* bypasses all hosts. Loopback hosts (localhost, .localhost, 127/8 and
::1, including trailing-dot localhost and IPv4-mapped 127/8 forms) always
connect directly unless a caller supplies an agent. Other bypassed
hosts still need direct DNS and connectivity.
SOCKS proxies, PAC discovery and integrated Windows proxy authentication require
a caller-provided agent.
Certificate and hostname validation remain enabled. For a TLS-inspecting corporate
proxy, configure Node's trusted CA before starting the process, for example with
NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem.
To override environment routing, wrap defaultTransportFactory and pass your own
agent (for custom CA, proxy authentication or PAC support). agent: false
explicitly selects a fresh direct agent. The SDK owns only agents it creates;
caller agents stay caller-owned. Proxy packages are loaded lazily by the Node
transport; browser transports and custom transport factories are unchanged.
Applications with their own routing policy must apply it to HTTP and WebSocket
connections together. CLI-specific overrides such as QODO_HTTPS_PROXY and
QODO_NO_PROXY are not SDK environment variables: an integrating application
must select and pass its proxy agent, or agent: false for a direct route.
Quick Start (2.0)
import { QodoClient } from '@qodo/sdk';
const client = new QodoClient({
baseUrl: 'https://qar.qodo.ai',
auth: 'env', // reads QODO_API_KEY; ~/.qodo/auth.key fallback
});
await client.connect();
for await (const ev of client.tasks.start({
agent_id: 'my-agent',
input: { user_query: 'Analyze this codebase and summarize the main components.' },
})) {
switch (ev.kind) {
case 'task.delta':
process.stdout.write(JSON.stringify(ev.payload.delta) + '\n');
break;
case 'task.done':
console.log(`\n[done status=${ev.payload.status}]`);
break;
case 'error':
console.error(`[error ${ev.payload.code}] ${ev.payload.message}`);
break;
}
}
await client.disconnect();for await is the canonical streaming primitive — there's no separate run() / stream() / prompt(). The full vocabulary of envelope kinds plus qar.client.* lifecycle events for reconnect/replay is documented in the Public API reference.
Attach files & images (multimodal input)
A turn can carry an image or PDF instead of a plain string. Pass an ordered content list of parts built with textPart / filePart / imagePart (mutually exclusive with user_query — set one, never both). File parts accept a Buffer, Uint8Array, ArrayBuffer, base64 string, or base64 data: URL, and require an explicit mediaType:
import { QodoClient, textPart, imagePart } from '@qodo/sdk';
import { readFile } from 'node:fs/promises';
const png = await readFile('./screenshot.png');
for await (const ev of client.tasks.start({
agent_id: 'my-agent',
input: {
// Order is meaningful — the text frames the image that follows it.
content: [
textPart('What does this screenshot show?'),
imagePart(png, { mediaType: 'image/png', filename: 'screenshot.png' }),
],
},
})) {
// … same event loop as above
}mediaType is always explicit (never inferred, even from a data: URL) so the wire part is unambiguous; which media types the runtime accepts and any per-message size caps are operator-side policy, not baked into the SDK. The builders and tasks.start* / tasks.continue reject a malformed turn (both user_query and content, an empty part list, a missing mediaType, …) synchronously with QodoContentValidationError, before the wire round-trip. See examples/sdk2/attach-image.ts.
Cross-pod session identity (opt-in)
For consumers that need cross-pod serialization of "the same logical work-unit" — Slack thread, GitHub PR, customer issue — pass idempotencyKey to opt into QAR's deterministic session-id derivation. Two pods receiving the same (tenant_id, idempotencyKey) derive the same session_id and serialize through QAR's lease table; without the field, every call gets a fresh session_id (DX default — right for one-shot scripts and single-process consumers).
const stream = client.tasks.startWithAgent(
{ agent: myAgent, input: { user_query: 'review this PR' } },
// Encode your fine scope into the key — two-layer scoping principle:
// tenant_id is added server-side; the consumer-supplied key carries
// the finer subject (PR number, thread id, issue id).
{ idempotencyKey: `${owner}/${repo}:pr:${prNumber}` },
);
// `stream.taskId` is synchronous and equals the FIRST outbound
// task.start.message_id. Use it for the pre-admission cancel race —
// `client.tasks.cancel(stream.taskId)` immediately after the start call.
console.log('taskId:', stream.taskId);
// `stream.admittedTaskId` is a Promise that resolves to the SERVER's
// canonical task_id once `task.started` arrives. On the omitted-key
// path it equals `taskId`. On the opt-in deterministic-key path with
// admission_in_progress retries it MAY DIFFER from `taskId` — the SDK
// rotates message_ids per retry while the canonical id is whatever
// the winning attempt's `task.started` reports. Use this id for
// cancel / continue / resubscribe AFTER admission completes.
const admittedTaskId = await stream.admittedTaskId;
// `stream.sessionId` resolves on the same admission ack. Await it
// before any cross-pod operation (e.g. forwarding to another pod via
// Bulletin for `task.resubscribe`).
const sessionId = await stream.sessionId;
for await (const ev of stream) { /* … */ }Both admittedTaskId and sessionId settle independently of consumer iteration — the SDK's internal admission driver advances the retry loop and resolves/rejects both Promises within PENDING_ADMISSION_TIMEOUT (5 min default). await stream.sessionId BEFORE the first for await cannot hang.
Validation: keys must be 1–512 Unicode code points with no NUL byte. Invalid keys throw QodoIdempotencyKeyValidationError synchronously, before the wire round-trip.
Don't pass Date.now() or a fresh randomUUID() per call — the first collides on rapid-fire calls within clock resolution; the second works but defeats the purpose. Omit the field instead and let the server mint.
Lost-ack retry semantics: the omitted-key path is intentionally non-idempotent across task.start retries. The SDK does NOT silently retry transport-failed task.start envelopes when idempotencyKey is absent — a retry without a sticky key starts a SECOND fresh session. Consumers requiring lost-ack idempotency MUST pass a deterministic key (domain-derived or a sticky per-attempt UUIDv7).
admission_in_progress retry: when two pods race the same (tenant_id, idempotencyKey), QAR returns the retryable admission_in_progress error. The SDK honors the server's retry_after_ms hint when present and falls back to exponential backoff with jitter (initial 100 ms, doubling, capped at 5 min) otherwise. After the cap the SDK throws QodoAdmissionTimeoutError carrying the scoped session_id so the caller can task.resubscribe if recovery is possible. The terminal admission_stalled server emission surfaces immediately as QodoAdmissionStalledError (same sessionId field).
Full design rationale and worked examples: QAR D-16 ADR.
Tools
Register a handler for inbound tool.request envelopes. The wire is parallel-batched: req.payload.calls: ToolCall[] (always a list, even for a single call) plus a required req.payload.node_name. Return one ToolResponseResult per call (positional array) or — when you know every request is single-call — a single result that the SDK auto-batches.
import { ToolDeniedError } from '@qodo/sdk';
const sub = client.tools.onRequest((req) =>
req.payload.calls.map((call) => {
if (call.name === 'echo') {
return { outcome: 'success', result: { echoed: call.arguments } };
}
if (call.name.startsWith('forbidden_')) {
// Return-style denial keeps per-call attribution; a `throw new
// ToolDeniedError(...)` would deny the whole batch.
return { outcome: 'denied', error: 'blocked by policy' };
}
return { outcome: 'error', error: `unknown tool: ${call.name}` };
}),
);
// later: sub.unsubscribe();Stacking handlers compose approval middleware (policy first, dispatcher second). Deep dive in the Tools guide.
Migrating from 1.x
TL;DR: QodoSDK → QodoClient, sdk.run/stream/prompt → for await client.tasks.start({...}), sdkAgent/sdkCommand removed (agents are server-side now), IDERetrievalRequest → ToolRequestEnvelope, Resume/ResumeAck machinery removed (server-driven replay), onTransportEvent removed (lifecycle events on the same iterator). Auth is unchanged.
Full guide with per-export migration table: docs/sdk2/migration-from-1x.md. Paired side-by-side examples: examples/migration-rosetta/.
Authentication
# Option 1: Environment variable (recommended)
export QODO_API_KEY=your_api_key_here
# Option 2: Auth file
mkdir -p ~/.qodo && echo "your_api_key_here" > ~/.qodo/auth.keyOn Windows (PowerShell):
$env:QODO_API_KEY = "your_api_key_here" # current session; use setx to persist
New-Item -ItemType Directory -Force "$HOME\.qodo" | Out-Null
Set-Content -NoNewline -Encoding ascii "$HOME\.qodo\auth.key" "your_api_key_here"In code, auth: 'env' reads QODO_API_KEY first and falls back to ~/.qodo/auth.key. Pass auth: { apiKey: '...' } to supply the token directly, or auth: { provider: () => Promise<string> } to plug in a custom token source.
TypeScript / Node
Full type definitions ship in the package. The SDK targets ES2022 + NodeNext modules, requires Node.js >= 20, and is ESM only — require() is not supported. See the Public API reference for the canonical exported surface.
Documentation
Full documentation lives in the repository. Repository access is currently restricted to Qodo organization members — if you need a deep reference link to resolve, contact [email protected].
2.0 (canonical)
Learn the SDK (in order):
| Track | Description |
|-------|-------------|
| Tutorial | 8 chapters: npm install → first task → tools → MCP → HITL → multi-agent → OTel → production checklist |
| Concept guides | Mental model behind each primitive: agent spec, tools, HITL, skills, sessions, observability, error handling |
| Use cases | Three flagship reference implementations: doc-qa, slack-bot, agentic-docs-updater |
| Cookbook | Lookup-shaped recipes: cancel, resume, reconnect, MCP subset, HITL, multi-agent, OTel, errors, testing, auth |
Reference (every field, every error):
| Guide | Description |
|-------|-------------|
| Migration from 1.x | Canonical 1.x → 2.0 upgrade guide; per-export table, recipes, FAQ |
| Public API | QodoClient, connect, tasks.start/continue/cancel, errors, backpressure |
| Tools | client.tools.onRequest / respond, stacking, error mapping |
| MCP tools | mcpTools projection, auto-discovery, fallback bridge |
| Reconnect + replay | Auto-reconnect, tasks.resubscribe, stale-anchor behaviour + replay TTL |
| Observability | OTel attribute taxonomy, span boundaries, skip-list, cardinality |
| Event surfaces | bulletin / artifacts / state / agents observers |
| Release policy | Versioning, branch rules, backport policy, EOL calendar |
Examples
End-to-end runnable scripts live in examples/. Most are env-gated on QAR_BASE_URL and exit cleanly when unset — safe to clone and run.
examples/sdk2/—hello-task,tool-roundtrip,multi-agent-observer, MCP auto-discovery, end-to-end live-cluster smoke.examples/tutorial/— companion code for the tutorial chapters (HITL, MCP, OTel, multi-agent).examples/usecases/— three flagship reference implementations: doc-qa, slack-bot, agentic-docs-updater.examples/migration-rosetta/— paired 1.x/2.0 files for every common upgrade pattern.
export QAR_BASE_URL=https://qar.dev.qodo.ai
export QODO_API_KEY=your_key
export QAR_AGENT_ID=health
npx tsx examples/sdk2/hello-task.tsLicense
See LICENSE. The SDK ships under an MIT-style license with a protective exception that bars re-use to build competing AI agent runtimes; consumer agents that interoperate with the Qodo Agent Runtime are unaffected.
Managed-tools API versions
QodoClient defaults to managed-tools API v1. To adopt the consolidated review Finding contract,
set managedToolsApiVersion: 2 in the client options. Discovery, invocation and operation polling
then use /v2/tools; the runtime must confirm v2 support. Missing v2 support produces an error
without silently falling back to v1. Other SDK surfaces retain their existing transport contracts.
MCP callers continue to use /v1/tools/mcp. After a runtime supporting the v2 review API is deployed,
callers can explicitly select /v2/tools/mcp for the new contract. The SDK option does not change MCP URLs.
