Official TypeScript SDK for the codai AI gateway β a single OpenAI-compatible endpoint with smart routing, sessions, server-side agents, streaming, embeddings, audio, outcome-billed tasks, shared sessions, devices, hosts, orgs and account β full parity with the public gateway OpenAPI.
- Zero dependencies β uses the platform
fetch. - Works in Node 18+ and modern edge runtimes.
- OpenAI-compatible chat surface with codai extensions.
- Fully typed β request/response types are generated from the OpenAPI spec.
- Every
operationIdof the gateway has exactly one method (parity-tested).
npm install codai-sdk
# or
pnpm add codai-sdkYou need a codai API key. Get one at codai.ro.
import { Codai } from 'codai-sdk';
const codai = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const res = await codai.chat({
messages: [{ role: 'user', content: 'Explain async iterators in one line.' }],
});
console.log(res.content);
console.log(res.routedTo); // which upstream model actually servedEverything hangs off the Codai client. The 0.2.x top-level methods (chat(),
chatStream(), embeddings(), models(), feedback(), mintToken(),
agents.run(), audio.*) still work; the resource groups below are the full
surface.
| Group | Methods | Gateway paths |
|---|---|---|
chat.completions |
create, stream |
POST /v1/chat/completions |
messages |
create, stream |
POST /v1/messages (Anthropic wire) |
responses |
create, stream |
POST /v1/responses (OpenAI Responses wire) |
embeddings |
create |
POST /v1/embeddings |
audio |
transcribe, transcribeDetailed, speech, speechDetailed |
POST /v1/audio/transcriptions, POST /v1/audio/speech |
tokens |
create |
POST /v1/tokens |
models |
list |
GET /v1/models |
health |
get, ready, status |
GET /health, GET /health/ready, GET /status |
agents |
run; runs.create, runs.get, runs.steps, runs.stats, runs.cancel, runs.stream |
/v1/agents/run, /v1/agents/runs/* |
tools |
search, fetch |
POST /v1/tools/search, POST /v1/tools/fetch |
tasks |
list, pending, stats, get, confirm |
/v1/tasks/* |
sessions |
create, list, get, update, delete, dispatch, stream; events.list/append; controls.list/submit/markApplied; lease.acquire/renew/release; shares.list/create/delete/sharedWithMe |
/v1/sessions/* |
devices |
list, update, delete, dispatchInbox |
/v1/devices/* |
hosts |
list, exec, postResult, stream |
/v1/hosts/* |
orgs |
create, list; members.list/add/remove |
/v1/orgs/* |
account |
get, update |
GET/PATCH /v1/account |
receipt |
get |
GET /v1/receipt |
feedback |
submit |
POST /v1/feedback |
phoneModels |
list |
GET /v1/phone/models |
resolve |
submit, get, accept, balance, attestation, keys, verifyAttestation, health, waitFor |
https://resolve.codai.ro/v1/resolve/* (own host) |
Every method takes an optional last argument { ext, signal } where ext is a
typed bag of X-Codai-* extension headers (see below).
for await (const delta of codai.chatStream({
messages: [{ role: 'user', content: 'Write a haiku about TypeScript.' }],
})) {
process.stdout.write(delta);
}After the stream ends, await .final for metadata (request id, token usage,
which model served, and any tool calls) β e.g. to submit feedback on a
streamed response:
const stream = codai.chatStream({
messages: [{ role: 'user', content: 'Write a haiku about TypeScript.' }],
});
for await (const delta of stream) {
process.stdout.write(delta);
}
const { requestId, usage, routedTo, toolCalls } = await stream.final;
if (requestId) await codai.feedback(requestId, 1);chat.completions.stream() is the same stream; stream.chunks() yields the raw
chat.completion.chunk objects.
// Anthropic Messages shape β text blocks, tool_use, streaming events
const msg = await codai.messages.create({
messages: [{ role: 'user', content: 'Salut!' }],
max_tokens: 256,
});
msg.text; // concatenated text blocks
for await (const ev of codai.messages.stream({ messages, max_tokens: 256 })) {
if (ev.type === 'content_block_delta') {
/* ev.delta */
}
}
// OpenAI Responses shape
const r = await codai.responses.create({ input: 'Say hi.' });
r.outputText;Run a plan-and-execute loop on the gateway β the heavy lifting (planning, tool use, iteration) happens server-side; your client stays thin.
const run = await codai.agents.run({
task: 'Summarize the key points of the provided text.',
context: 'β¦your inputβ¦',
});
console.log(run.result);Async runs are persisted and can be polled, streamed and cancelled:
const { id } = await codai.agents.runs.create({ task: 'β¦', client_request_id: 'ci-8812' });
for await (const ev of codai.agents.runs.stream(id)) {
if (ev.event === 'step') console.log(ev.data.kind, ev.data.summary);
if (ev.event === 'done') console.log(ev.data.status, ev.data.result);
}
const run = await codai.agents.runs.get(id);const hits = await codai.tools.search({ query: 'BNR EUR RON' });
const page = await codai.tools.fetch({ url: 'https://bnr.ro', format: 'markdown' });
const { tasks, next_cursor } = await codai.tasks.list({ outcome: 'unconfirmed', limit: 20 });
await codai.tasks.confirm(tasks[0].id, { outcome: 'confirmed' });
const receipt = await codai.receipt.get({ sessionId: 'my-project' });
receipt.cost_usd;client.resolve talks to the separate Resolve service (https://resolve.codai.ro,
override with resolveBaseUrl) with the same API key. You submit a public GitHub
repo + issue, triage quotes a tier (or declines), you accept, and only a verified
fix is billed β with a public attestation. Amounts are USD micros.
import { ResolveInsufficientFundsError } from 'codai-sdk';
const q = await codai.resolve.submit({
repo_url: 'https://github.com/psf/requests',
issue_url: 'https://github.com/psf/requests/issues/6000', // or issue_text
test_command: 'python -m pytest -q tests/test_x.py::test_y', // optional
});
if (q.status === 'declined') throw new Error(q.decline_reason);
if (q.status === 'quoted') {
try {
await codai.resolve.accept(q.id); // free-tier jobs are auto-accepted
} catch (err) {
if (err instanceof ResolveInsufficientFundsError)
console.log('top up:', err.topupUrl, err.bundleUrl ?? ''); // bundleUrl: t7 only
throw err;
}
}
const job = await codai.resolve.waitFor(q.id, { intervalMs: 5_000, timeoutMs: 30 * 60_000 });
if (job.status === 'resolved' && job.attestation_url) {
const proof = await codai.resolve.attestation(job.attestation_url);
console.log(proof.patch);
}Errors are ResolveError (a CodaiError with status and detail = the flat
error string or Zod details); a 402 on accept is ResolveInsufficientFundsError
with balance, required, currency: 'micro_eur' and topupUrl. submit and
accept are never retried. waitFor stops at resolved | failed | declined | error
(or until), and throws ResolveError with status: 0 on timeout.
Pricing. Your first paid accept holds β¬1 instead of the list price (the quote then
carries intro_price_micro_eur / list_price_micro_eur); it is used up only by a verified
fix β a failed intro job is refunded and the intro is offered again. A fix bundle (β¬49 = 10
credits, https://pay.codai.ro/checkout?bundle=resolve10) pays for t7 jobs only: a credit is
taken at accept instead of a wallet hold and returned on failure. Precedence:
free > intro > credit (t7) > wallet; accept() reports it in paid_with, and a t7 402
carries bundleUrl. codai.resolve.balance() returns fix_credits, intro_available and
wallet_micro_eur.
Pass callback_url (https, public host) on submit; the response carries
callback_secret once β store it. Resolve then POSTs job.quoted,
job.declined, job.resolved, job.failed and job.error events signed with
x-codai-signature: t=<unix>,v1=<hex HMAC-SHA256>. Verify against the raw body
with verifyResolveWebhook (server-side, uses node:crypto; 300 s tolerance by
default). Up to 3 attempts on 5xx/429/network errors; deduplicate on
x-codai-delivery; delivery never changes job state β get(id) stays the truth.
import { verifyResolveWebhook, type ResolveWebhookEvent } from 'codai-sdk';
const q = await codai.resolve.submit({
repo_url: 'https://github.com/psf/requests',
issue_url: 'https://github.com/psf/requests/issues/6000',
callback_url: 'https://ci.example.com/hooks/codai-resolve',
});
await saveSecret(q.id, q.callback_secret!); // shown once
// in your webhook handler (Hono / Next route / express.raw):
const raw = await req.text();
if (!verifyResolveWebhook(secret, raw, req.headers.get('x-codai-signature'))) {
return new Response('bad signature', { status: 401 });
}
const evt = JSON.parse(raw) as ResolveWebhookEvent; // evt.event, evt.job.attestation_urlAttestations created from 2026-09-27 carry signature: a DSSE envelope
(ECDSA P-256/SHA-256) over a canonical statement of sha256 hashes of the patch,
repro test and test outputs, plus the commands, base commit, runner digest and
timestamps. verifyAttestation checks the signature against
GET /v1/resolve/keys and that the statement matches the fields you were
served. Older attestations have signature: null β { ok: false, reason: 'UNSIGNED' }.
const proof = await codai.resolve.attestation(job.attestation_url!);
const r = await codai.resolve.verifyAttestation(proof); // or { keys: pinnedKeys }
if (!r.ok) throw new Error(`attestation not verified: ${r.reason}`);
// r.keyid β the signing key; reasons: UNSIGNED | BAD_SIGNATURE | STATEMENT_MISMATCH | BAD_PAYLOAD_TYPEOffline (no network): verifyAttestationWithKeys(proof, keys). Server-side only (node:crypto).
Shared-sessions and hosts calls need a device UUID (x-codai-device). Set it once
on the client:
const codai = new Codai({
apiKey,
device: '6f1c2b3a-4d5e-4f60-8a9b-0c1d2e3f4a5b',
deviceName: 'ci-runner',
devicePlatform: 'cli',
});
const session = await codai.sessions.create({ session_key: 'desktop-main', title: 'Refactor' });
await codai.sessions.lease.acquire(session.id);
await codai.sessions.events.append(session.id, {
events: [{ kind: 'user', payload: { text: 'hello' }, client_event_id: 'e1' }],
});
for await (const frame of codai.sessions.stream(session.id, { after: 0 })) {
// frame.event β 'event' | 'control' | 'lease' | 'presence'
}
await codai.sessions.dispatch(session.id, { device_id: phoneId, text: 'continue on the phone' });
// Host relay: run one op on a connected desktop
const hosts = await codai.hosts.list();
const out = await codai.hosts.exec(hosts[0].device_id, 'shell', { cmd: 'git status' });const org = await codai.orgs.create('Acme Robotics');
await codai.orgs.members.add(org.id, { email: '[email protected]', role: 'admin' });
await codai.sessions.shares.create(session.id, {
principal_type: 'org',
principal_id: org.id,
role: 'editor',
});
const me = await codai.account.get();
await codai.account.update({ training_opt_out: true });const res = await codai.chat({ messages: [{ role: 'user', content: 'hi' }] });
if (res.requestId) {
await codai.feedback(res.requestId, 1); // 1 = π, -1 = π
}const { embeddings } = await codai.embeddings({ input: ['hello', 'world'] });// Speech-to-text
const text = await codai.audio.transcribe({ file: audioBytes, filename: 'clip.webm' });
// Text-to-speech
const wav = await codai.audio.speech({ input: 'Hello from codai.' });const models = await codai.models();const codai = new Codai({
apiKey: process.env.CODAI_API_KEY!,
baseUrl: 'https://ai.codai.ro', // default
resolveBaseUrl: 'https://resolve.codai.ro', // default (client.resolve)
sessionId: 'my-project', // enables session memory + stickiness
device: '<uuid>', // shared sessions / hosts (x-codai-device)
client: 'my-app/1.2.0', // x-codai-client surface tag
defaults: { effort: 'medium' }, // any other X-Codai-* defaults
timeoutMs: 120_000,
maxRetries: 2,
});The chat surface is OpenAI-compatible, with opt-in extensions. On chat() the
0.2.x option names still work:
| Option | Description |
|---|---|
sessionId |
Stable conversation id β enables session memory and routing stickiness. |
agentMode |
Plan-and-execute agent mode (Pro+). |
compact: "auto" |
Server-side context compaction. |
bestOf |
Best-of-N sampling override (0 disables, 3 forces). |
Every method also accepts { ext } β a typed CodaiRequestExtensions bag that
covers all 41 documented X-Codai-* request headers (effort, thinking,
thinkingBudget, cache, noTask, taskId, incognito, noRecall,
provenOnly, repo, agentId, disableSubagents, mode, serverTools,
orchestrate, cascade, bestOf, reflect, stepVerify, plan, consensus,
compact, retrieval, heuristics, identity, playbook, debug, device,
shareToken, β¦):
await codai.chat.completions.create(
{ messages, model: 'codai' },
{ ext: { effort: 'high', thinking: true, thinkingBudget: 8192, taskId: 'task_42' } },
);paths, components and operations from the gateway OpenAPI are exported, and
every resource re-exports friendly aliases (ChatCompletionRequest, Task,
Session, AccountView, β¦):
import type { components, Task, SessionStreamEvent } from 'codai-sdk';
type Receipt = components['schemas']['AccountReceipt'];Resolve types are exported as ResolvePaths, ResolveComponents and
ResolveOperations (plus aliases such as ResolveJob, ResolveQuote).
Regenerate after a spec change with pnpm gen (gateway + resolve);
operations.test.ts and operations.resolve.test.ts fail when a spec and the
client drift.
The chat payload is OpenAI-shaped, so migration is mostly swapping the client:
// before: openai.chat.completions.create({ model, messages })
// after:
const res = await codai.chat({ messages });import { Codai, CodaiError } from 'codai-sdk';
try {
await codai.chat({ messages: [{ role: 'user', content: 'hi' }] });
} catch (err) {
if (err instanceof CodaiError) {
console.error(err.status, err.code, err.requestId, err.retryAfter, err.body);
}
}code is the stable gateway error code (invalid_api_key, rate_limit_exceeded,
quota_exceeded, lease_held, host_offline, β¦); retryAfter is the
Retry-After value in seconds on 429s.
Nothing breaks: every 0.2.x method keeps its signature. What changed underneath:
chat,embeddings,modelsandfeedbackare now callable resource groups βcodai.chat({...})still works andcodai.chat.completions.create({...})is the same call with the raw OpenAI body.ChatResultgainedeventId,toolCalls,headers;ChatStreamResultgainedfinishReason,headers,usage.cachedTokens.CodaiErrorgainedcode,requestId,retryAfter.- Streams are no longer subject to
timeoutMsand are never retried. - New client options:
device,deviceName,devicePlatform,client,defaults,fetch.
MIT Β© codai