# @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`](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/migration-from-1x.md), with paired side-by-side examples in [`examples/migration-rosetta/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/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 the `release/1.x` branch with security and critical-bug patches only, on a calendar tied to command-backend's Sept 2026 EOL.

## Installation

```bash
# 2.0 (stable — QAR-native)
npm install @qodo/sdk

# 1.x (legacy, command-backend-native — published as 0.13.x)
npm install @qodo/sdk@0.13
```

`@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)

```typescript
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](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/public-api.md).

### 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`:

```typescript
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`](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/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).

```typescript
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](https://github.com/qodo-ai/qodo-agent-runtime/blob/ec7705f96ecadf728d27ea022c51923a75ec93f8/knowledge/decisions/D-16-session-identity.md).

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

```typescript
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](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/tools.md).

### 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`](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/migration-from-1x.md). Paired side-by-side examples: [`examples/migration-rosetta/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/migration-rosetta).

## Authentication

```bash
# 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.key
```

On Windows (PowerShell):

```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](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/public-api.md) 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 [support@qodo.ai](mailto:support@qodo.ai).

### 2.0 (canonical)

**Learn the SDK** (in order):

| Track | Description |
|-------|-------------|
| [Tutorial](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/tutorial/00-overview.md) | 8 chapters: `npm install` → first task → tools → MCP → HITL → multi-agent → OTel → production checklist |
| [Concept guides](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/concepts/00-index.md) | Mental model behind each primitive: agent spec, tools, HITL, skills, sessions, observability, error handling |
| [Use cases](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/usecases.md) | Three flagship reference implementations: doc-qa, slack-bot, agentic-docs-updater |
| [Cookbook](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/cookbook/00-index.md) | 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](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/migration-from-1x.md) | Canonical 1.x → 2.0 upgrade guide; per-export table, recipes, FAQ |
| [Public API](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/public-api.md) | `QodoClient`, connect, `tasks.start/continue/cancel`, errors, backpressure |
| [Tools](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/tools.md) | `client.tools.onRequest` / `respond`, stacking, error mapping |
| [MCP tools](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/mcp-tools.md) | `mcpTools` projection, auto-discovery, fallback bridge |
| [Reconnect + replay](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/reconnect-replay.md) | Auto-reconnect, `tasks.resubscribe`, stale-anchor behaviour + replay TTL |
| [Observability](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/observability.md) | OTel attribute taxonomy, span boundaries, skip-list, cardinality |
| [Event surfaces](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/sdk2/event-surfaces.md) | `bulletin` / `artifacts` / `state` / `agents` observers |
| [Release policy](https://github.com/qodo-ai/qodo-ts-sdk/blob/main/docs/release-policy.md) | Versioning, branch rules, backport policy, EOL calendar |

## Examples

End-to-end runnable scripts live in [`examples/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples). Most are env-gated on `QAR_BASE_URL` and exit cleanly when unset — safe to clone and run.

- [`examples/sdk2/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/sdk2) — `hello-task`, `tool-roundtrip`, `multi-agent-observer`, MCP auto-discovery, end-to-end live-cluster smoke.
- [`examples/tutorial/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/tutorial) — companion code for the tutorial chapters (HITL, MCP, OTel, multi-agent).
- [`examples/usecases/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/usecases) — three flagship reference implementations: doc-qa, slack-bot, agentic-docs-updater.
- [`examples/migration-rosetta/`](https://github.com/qodo-ai/qodo-ts-sdk/tree/main/examples/migration-rosetta) — paired 1.x/2.0 files for every common upgrade pattern.

```bash
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.ts
```

## License

See [LICENSE](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.
