Skip to content
codai-roPublic

About

Official TypeScript SDK for codai - OpenAI-compatible client for the codai gateway. npm: codai-sdk

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

codai-sdk

npm version license

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 operationId of the gateway has exactly one method (parity-tested).
npm install codai-sdk
# or
pnpm add codai-sdk

You need a codai API key. Get one at codai.ro.

Quickstart

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 served

Resource groups

Everything 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, iphone, 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
billing get, invoices, watchBalance (read-only) GET /v1/billing, GET /v1/billing/invoices
referrals get, redeem, setConsent GET /v1/referrals, PATCH /v1/account
resolve submit, get, list, iterate, 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).

Lists and the share sheet

lists covers /v1/lists/* and POST /v1/share: list, create, reorder, update, delete, items, add, updateItem, deleteItem, image (bytes + content type), exportMarkdown (string), share and stream (live list events). add and share are idempotent on client_id / the canonical URL: a duplicate answers 200 with created: false.

const { item, list } = await client.lists.share({ url: 'https://example.com', auto: true });
const md = await client.lists.exportMarkdown(list.id);
for await (const ev of client.lists.stream()) console.log(ev.op, ev.listId, ev.itemId);

schedules.audit({ schedule_id?, limit? }) returns the scheduler audit log (GET /v1/schedules/audit), newest first.

Streaming

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 and OpenAI Responses wires

// 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;

Server-side agent

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);

Tools, tasks and receipts

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;

Resolve β€” verified bug fixes

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.

Webhooks instead of polling

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_url

Verifying a signed attestation

Attestations 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_TYPE

Offline (no network): verifyAttestationWithKeys(proof, keys). Server-side only (node:crypto).

Shared sessions, devices and hosts

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' });
// Handoff: just open the session on the phone, run nothing (`online` comes from devices.list()).
await codai.sessions.dispatch(session.id, { device_id: phoneId, mode: 'open' });

// 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' });

// iPhone through the codai iPhone bridge (host with cap `iphone`); result = { data, image? }
const phone = hosts.find((h) => h.caps.includes('iphone'));
if (phone) await codai.hosts.iphone(phone.device_id, 'tap', { x: 200, y: 640 });

// Sandbox terminal (WebSocket, Bearer header on the upgrade β€” Node with `ws`, not browsers)
const ptyUrl = codai.sandboxes.ptyUrl(sandboxId, { cols: 120, rows: 32 });

Orgs and account

const org = await codai.orgs.create('Acme Robotics');
await codai.orgs.members.add(org.id, { email: 'ana@example.com', 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 });

Guest chat (no account)

Up to 3 guest chats of 10 messages per UTC day, without an account. Start a session only after the user accepted the Terms and confirmed they are 18+ (startGuestSession sends both as true):

import { Codai } from 'codai-sdk';

// challenge + session need no key; any placeholder works.
const guest = new Codai({ apiKey: 'guest' });
const deviceId = crypto.randomUUID(); // generate once per install and persist it
const session = await guest.anon.startGuestSession({ surface: 'desktop', deviceId });
// challenge -> proof-of-work (solvePow, WebCrypto SHA-256) -> POST /v1/anon/session

// The guest token IS the api key for chat and status.
const chat = new Codai({ apiKey: session.token });
const reply = await chat.chat.completions.create({
  messages: [{ role: 'user', content: 'Salut!' }],
});
const { limits, budget_open } = await chat.anon.status();

// After sign-up, import the locally kept history once, with the NEW account key.
const account = new Codai({ apiKey: process.env.CODAI_API_KEY! });
await account.anon.importChat({
  anon_token: session.token,
  messages: history,
  title: 'Guest chat',
});

Web and mobile clients may call anon.challenge() / anon.createSession() directly with a Turnstile, Play Integrity or App Attest proof instead of proof-of-work. Errors (anon_session_limit, anon_message_limit, anon_budget_exhausted, …) arrive as CodaiError with the code and details.signup_url.

Host apps: keys, overrides and subscriptions (CodaiApps)

Apps that resell codai to their own users (e.g. studiai.ro) manage keys through the mgmt API (https://api.codai.ro) with an app connection token (codai_app_…, created at codai.ro/console/apps/<app>), not a user key. CodaiApps unwraps the { ok, data } envelope and throws CodaiError (status, code such as missing_scope:keys.mint or no_key_for_external_id). Default timeout 20 s, one retry on 429/5xx; keys.mint is never retried.

import { CodaiApps } from 'codai-sdk';

const apps = new CodaiApps({
  appToken: process.env.CODAI_APP_TOKEN, // /apps/* calls
  validateToken: process.env.CODAI_VALIDATE_TOKEN, // optional, validateKey only
});

const minted = await apps.keys.mint({
  email: 'ana@example.com',
  endUserExternalId: 'uid:gh1',
  tierSlug: 'pro',
});
const keys = await apps.keys.list({ endUserExternalId: 'uid:gh1' });
const usage = await apps.keys.usage(minted.id, 30);
await apps.keys.update(minted.id, { enabled: false });
await apps.keys.revokeAllFor('uid:gh1');

await apps.users.setOverride('uid:gh1', { tasksPerDay: 200, resetUsage: true });
const { override, effectiveLimits } = await apps.users.getOverride('uid:gh1');
await apps.users.clearOverride('uid:gh1');

await apps.subscriptions.sync({ email: 'ana@example.com', event: stripeEvent });
const check = await apps.validateKey(presentedKey); // { valid, key?, entitlements?, reason? }

Feedback

const res = await codai.chat({ messages: [{ role: 'user', content: 'hi' }] });
if (res.requestId) {
  await codai.feedback(res.requestId, 1); // 1 = πŸ‘, -1 = πŸ‘Ž
}

Embeddings

const { embeddings } = await codai.embeddings({ input: ['hello', 'world'] });

Audio

// 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.' });

List models

const models = await codai.models();

Configuration

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,
});

codai extensions

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' } },
);

Generated types

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.

Migrating from the OpenAI SDK

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 });

Error handling

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.

Migrating from 0.2.x

Nothing breaks: every 0.2.x method keeps its signature. What changed underneath:

  • chat, embeddings, models and feedback are now callable resource groups β€” codai.chat({...}) still works and codai.chat.completions.create({...}) is the same call with the raw OpenAI body.
  • ChatResult gained eventId, toolCalls, headers; ChatStreamResult gained finishReason, headers, usage.cachedTokens.
  • CodaiError gained code, requestId, retryAfter.
  • Streams are no longer subject to timeoutMs and are never retried.
  • New client options: device, deviceName, devicePlatform, client, defaults, fetch.

License

MIT Β© codai

About

Official TypeScript SDK for codai - OpenAI-compatible client for the codai gateway. npm: codai-sdk

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages