npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kortix/sdk

v0.15.11

Published

Official Kortix frontend TypeScript SDK — one typed client for the Kortix API. Unifies project/session lifecycle and live agent streaming behind a single ergonomic Session handle, so web, mobile, and white-label apps never touch the raw HTTP API or the ag

Readme

@kortix/sdk

The single, opinionated data layer for the Kortix API. One typed client wraps both the Kortix REST API and the agent runtime so a host app — web, mobile, reference — imports only @kortix/sdk. The SDK owns its types: the transcript is Kortix's own format (kortix.transcript.v1), and the package depends on no harness SDK. (The no-raw-backendApi/authenticatedFetch rule below is the target state, not yet fully true of apps/web — see Rules of the road.)

Philosophy: one Kortix token, one client, every action a method. Keys never leave the server; mutations own their side-effects there; the host states intent.

📖 Full documentation: kortix.com/docs/#sdk — getting started, the full client, sessions, React hooks, and the subpath modules. The REST API has an auto-generated reference at api.kortix.com/v1/docs.


Install

npm install @kortix/sdk
import { createKortix } from "@kortix/sdk";

const kortix = createKortix({
  backendUrl: "https://api.kortix.com/v1",
  getToken,
});
await kortix.workspaces.list();

Inside a Kortix sandbox (a session, or a program agent's turn), call createKortix() with no options. It reads the sandbox's own KORTIX_API_URL and KORTIX_TOKEN, so every call acts as the session's agent:

const kortix = createKortix();
await kortix.project(process.env.KORTIX_PROJECT_ID!).sessions.list();

Call external systems through Connectors

Use one data plane for every Connector provider. A user token binds the project explicitly. An agent-minted session token already carries its project scope, so it can use the top-level fallback.

const connectors = projectId
  ? kortix.project(projectId).connectors
  : kortix.connectors;

await connectors.catalog();
await connectors.tools();
await connectors.search('send email');
await connectors.describe('gmail.send_email');
await connectors.call('gmail.send_email', { to, subject, body });
await connectors.accounts('gmail');
const { ref } = await connectors.uploadAttachment(bytes, {
  filename: 'invoice.pdf',
  contentType: 'application/pdf',
  connector: 'microsoft-graph', // the connector the file is for
});
// `ref` is { $kortix_attachment: '<id>' }. The gateway swaps in the file:
// as an attachments[] element it becomes the provider's attachment item,
// in a string field it becomes the base64. The bytes never enter call args.
await connectors.call('microsoft-graph.sendmail', {
  user: '[email protected]',
  body: { message: { subject: 'Invoice', attachments: [ref] } },
});

A Connector defines callable tools. A Connection stores one authorization for that Connector. Credentials remain server-side and never enter the sandbox.

Typed calls

kortix connectors types --out kortix-connectors.d.ts writes a declaration file that fills ConnectorActionRegistry. callAction then types args and output from it. The request and the result are the same as call:

const r = await connectors.callAction('linear', 'list_issues', { team: 'CORE' });
r.output?.issues; // typed from the action's output schema

One connector as a handle: run returns the output itself and throws ConnectorCallError (code, connectUrl, availableAccounts, upstreamStatus, retryAfterSeconds) or ConnectorApprovalPendingError; paginate follows a cursor and throws ConnectorPageLimitError (with nextArgs) past maxPages; useConnectorQuery (@kortix/sdk/react) caches a read. Guide: https://kortix.com/docs/#call-a-connector; runnable: examples/13-connectors-as-code.ts.

const linear = kortix.project(projectId).connector('linear');
const { issues } = await linear.run('list_issues', { team: 'CORE' });
await linear.describe(); // actions with input and output schemas
await linear.accounts();

An action outside the file accepts any object and returns output: unknown. Managed Composio and Pipedream connectors publish no output schema, so their output stays unknown. ConnectorArgs<'linear', 'list_issues'> and ConnectorResult<'linear', 'list_issues'> name the same types.

Choose which account a call runs as

One Connector can hold the project's shared account and each member's own. List the accounts a caller may use, then name one on the call:

const accounts = await connectors.accounts('gmail');
// [{ connection_id, label, owner_type: 'project' | 'member', is_default }]

const result = await connectors.call('gmail.send_email', { to, subject, body }, {
  account: 'Support inbox',   // a label, a connection id, `me`, or `project`
});
result.account; // { connection_id, label, owner_type } — the identity that ran

account takes a connection label (case-insensitive), a connection id, or one of two selector words: me (the caller's own default private account) and project (the default account shared with the whole project). Omit it and resolution takes the caller's own default first, then the project's.

A named account is never silently substituted. If it does not match one this caller is entitled to, the call is denied with connector_not_connected and the denial lists the accounts that were available. Every successful call echoes account, so a transcript can always show which identity acted.

Nothing connected yet? Start a hosted authorization and say who the new account belongs to:

await project.connectors.pipedream.connect('gmail', { owner: 'me' });      // my own
await project.connectors.pipedream.connect('gmail', { owner: 'project' }); // shared

// Or hand a human a link instead of authorizing inline:
await project.setupLinks.requestConnector({ slug: 'gmail', owner: 'project' });

owner defaults to me. Creating a project-owned account requires project.connector.write.

Call from an App, a Convex action, or a script

The call is the same everywhere. The credential decides which accounts it reaches:

| Where the code runs | createKortix options | Acts as | Reaches | |---|---|---|---| | App, browser | backendUrl: '/_kortix/api/v1', getToken: kortixAppViewerToken() | the viewer | shared accounts the viewer may use, and the viewer's own private accounts | | App, server | createAppViewerKortix(request, { backendUrl }) | the viewer | the same | | Convex action, App job with no viewer | getToken: async () => process.env.KORTIX_API_KEY! (a kortix_sa_… service account bearer a person minted) | the service account | shared accounts nobody narrowed; never a private account | | External program, CI | getToken: async () => process.env.KORTIX_API_KEY! (a kortix_pat_…) | you | your shared and private accounts |

The browser path needs the App's viewer scope set to api (kortix apps access <app> --viewer api); with identity a call answers 403 insufficient_scope. A service account answers 403 until a person grants it a project role (kortix access grant --service-account <id> --role member --project <id>). Never put a provider API key in an App or a Convex deployment when a connector exists. Guide: https://kortix.com/docs/#call-a-connector.

Upload prompt attachments before Send

Create one controller per composer. add(file) starts a private project upload without waiting for a session runtime. Subscribe to getSnapshot() for tile state.

const attachments = kortix.project(projectId).attachments.createController();
const localId = attachments.add(file);
const unsubscribe = attachments.subscribe(() => render(attachments.getSnapshot()));

// Inside the submit handler. Send never waits for uploads.
const ids = attachments.getSnapshot().attachments.map((item) => item.id);
attachments.submit(ids); // hand-off: the composer clears, the uploads continue
paintMessage(text, ids);
try {
  const parts = await attachments.whenReady(ids); // handle-only parts, in `ids` order
  await kortix.session(projectId, sessionId).prompts.create({
    clientMessageId,
    messageId,
    parts: [{ type: 'text', text }, ...parts],
  });
  attachments.forget(ids); // release; does not delete storage objects
} catch (error) {
  attachments.reclaim(ids); // back to the composer, with the failed file's state
}

React consumers use usePromptAttachments(projectId) from @kortix/sdk/react. It returns the controller methods plus the reactive attachments list. It omits dispose, subscribe, and getSnapshot, and keeps its identity until the list changes.

Files move through pending, uploading, processing, ready, error, or aborted. Progress counts bytes sent. Progress snapshots are throttled: one per whole-percent change, at most ten per second per upload. The default concurrency is two files. Limits are 50 MiB per file, 100 MiB per message, and 20 files. Empty files are rejected. Refuse Send only while a selected file is error or aborted.

whenReady(ids, { signal }) resolves once every upload is ready. It rejects when one fails, is aborted or removed, or signal aborts. A rejected wait does not stop the upload.

Ownership: submit(ids) hands entries to one send. They leave attachments and stop counting toward the limits. dispose() aborts and deletes only listed work, so a composer that unmounts after Send (a navigation, a remount) does not cancel its held uploads. The controller object lives as long as the send's whenReady promise references it. Call forget(ids) after the prompt POST succeeds, or reclaim(ids) when a failed send restores its draft. forget() with no argument releases only the listed selection. A host that keeps a failed send on screen keeps its entries: retry(id) reaches a handed-off entry after dispose().

retry(localId) resumes the same upload and preserves the original File. After attachment_size_mismatch or attachment_failed the server keeps no usable handle, so retry uploads the File again as a new attachment. An expired upload cannot retry: retry throws, and the item error carries code attachment_expired. remove(localId) removes the entry, aborts its upload, and resolves at once. It deletes unbound storage best-effort and never rejects; a failed or refused DELETE leaves the object to the 24-hour expiry. abort(localId) cancels unfinished work. dispose() aborts listed work and deletes its uploads best-effort: no send holds them. Call it on non-React cleanup; the hook handles unmount and project changes. Unused uploads expire after 24 hours.

A draft may keep a finished upload, so a reload or another composer brings the file back. Store its metadata (item.attachment: attachment_id, filename, mime, size, expires_at) and call forget(ids) before dispose(), so the upload is not deleted. Later, restore([{ attachment, file? }]) lists it again as ready with no request, and returns its new id (undefined for an expired or invalid one). Send uses it like any ready upload. The server binds an attachment only for its own account, project and person, so the id grants nothing to anyone else. Never persist a File, a blob URL, or a signed URL: an unfinished upload cannot be kept.

For non-composer uploads, call kortix.project(projectId).attachments.upload(file, { signal, onProgress, onUpload, resume }). The server selects the transport in the handle's upload field:

  • kind: 'direct' (default): one PUT of the whole file to upload.url with upload.headers and no Authorization header. Hosts with XMLHttpRequest (browsers, React Native) report sent bytes; other hosts use fetch and report 0, then the full size. An expired URL, or one Storage refuses with 400/401/403, is re-signed once for the same attachment_id; the server creates no second upload. A 409 from Storage means an earlier attempt already stored the file.
  • kind: 'chunked': sequential authenticated PUTs of upload.chunk_size bytes. The SDK accepts any positive chunk_size. Only a deployment whose edge drops large request bodies selects it.

Completion then verifies the stored bytes. Retain the onUpload handle for manual same-ID recovery. If completion answers 409 attachment_not_uploaded, onUpload reports the direct handle with received_bytes: 0, so a resume sends the file again. Initiation, the upload, and completion retry timeouts, network errors, 429, and 5xx with jittered exponential backoff. The budget is 60 seconds from the first failure, so a long upload that fails late still retries. Completion also retries attachment_processing, with a five-minute budget. Initiation never retries 402 (a BillingError: the account cannot run) or 429 attachment_budget_exceeded (40 unfinished uploads or 500 MiB of unsent uploads for the user; unused uploads expire within 24 hours). The server answers or refuses one completion within 105 seconds; each completion request allows 120 seconds. Caller aborts never retry.

A sent attachment's reference is released 1 hour after its prompt is delivered, and when its session or project is deleted. The next maintenance sweep then removes the file, and its attachment_id can no longer be sent. Completed attachment_id parts use platform prompt routes. Runtime sendParts continues to accept runtime URL parts. Legacy platform URL parts remain supported.

No bundler, no framework

The published package ships a browser IIFE bundle alongside its ESM dist/ — no build step required:

<script src="https://unpkg.com/@kortix/sdk"></script>
<script>
  const kortix = Kortix.createKortix({ backendUrl, getToken });
</script>

CORS: a <script> page calls the API from its own origin, so that origin must be in the API's CORS allowlist. Kortix's own domains and localhost:3000/3010 are allowed out of the box; any third-party origin (or a local page on another port) needs adding via the API's CORS_ALLOWED_ORIGINS — otherwise the browser blocks the request before it leaves the page. A Kortix-hosted App needs no allowlist entry: it sets backendUrl: '/_kortix/api/v1', its own origin (see "A Kortix-hosted App is already signed in").

Entry points

@kortix/sdk is the canonical entry — everything framework-free lives there. Nine others are stable, each for a reason that fits in one sentence:

| Entry | Why it is separate | | ------------------------------- | ---------------------------------------------------------- | | @kortix/sdk/react | React is a peer dependency | | @kortix/sdk/server | imports node:async_hooks | | @kortix/sdk/genui/fence | splits openui blocks out of text and loads no parser | | @kortix/sdk/genui | needs the optional peers zod and @openuidev/lang-core | | @kortix/sdk/genui/react | React and the Generative UI peers | | @kortix/sdk/wire-message-id | the message-id mint alone, one file with no imports (also at root) | | @kortix/sdk/wire-values | the session wire value sets alone, one file with no imports (also at root) | | @kortix/sdk/workspace-search | the workspace search client; the root does not export it | | @kortix/sdk/capture-rules | the Kortix Capture constants alone | | @kortix/sdk/internal/* | unsupported, outside semver |

Install the optional peers before you use the React entry:

npm install @kortix/sdk react @tanstack/react-query

The older subpaths (@kortix/sdk/projects-client, /turns, …) were removed in 0.15.0. Import from the root instead — see Entry points below.

React Native / Expo: REST works. Live streaming works with a host transport: pass configureKortix({ eventStreamTransport }) (one connection's messages over the wire your runtime has, e.g. react-native-sse), and report foreground and network changes with notifyHostSignal. apps/mobile runs useSession from @kortix/sdk/react this way.

Quick start

import { createKortix } from "@kortix/sdk";

const kortix = createKortix({
  backendUrl: "https://api.kortix.com/v1",
  getToken: () =>
    supabase.auth
      .getSession()
      .then((s) => s.data.session?.access_token ?? null),
});

// Workspaces (a workspace id is the project id: `kortix.project(pid)` still works)
const workspaces = await kortix.workspaces.list();
const detail = await kortix.workspace(pid).detail();
await kortix.project(pid).secrets.upsert({
  name: "LOCAL_TOOL_TOKEN",
  value,
  strategy: "runtime",
  consumer: "sandbox",
});
// Who can use a value: [] = everyone (default), or people and groups. A
// narrowed value reaches only them — directly, or in their own private
// sessions; never a shared session or a trigger.
await kortix.project(pid).secrets.upsert({
  name: "DEEL_API_TOKEN",
  value: deelToken,
  strategy: "broker",
  consumer: "connector",
  shared_with: [{ principal_type: "user", principal_id: userId }],
});
await kortix.project(pid).secrets.upsert({
  identifier: "anthropic-primary",
  name: "ANTHROPIC_API_KEY",
  value: providerKey,
  strategy: "broker",
  consumer: "llm_gateway",
});
const visibleSessions = await kortix.project(pid).sessions.list();
const projectInventory = await kortix
  .project(pid)
  .sessions.list({ scope: "project" }); // manager only; inaccessible rows omitted
// Top-level sessions the caller started; spawned sessions load under their parent.
const mine = await kortix.project(pid).sessions.list({ parent: "root", startedBy: "me" });
const found = await kortix.project(pid).sessions.list({ parent: "root", q: "nightly" }); // searches every visible session
const spawned = await kortix.project(pid).sessions.list({ parent: mine[0].session_id });
// React: useWorkspaceSessions(pid, { parent: "root", startedBy: "me" }), useSessionChildren(pid, parentId)

// Sessions (id-bound handle)
const s = kortix.session(pid, sid);
const cost = await s.cost(); // reads finalized LLM + compute cost; no runtime start
await s.send("Build me a widget"); // provisions/resumes if needed, then prompts
await s.rewind(userMessageId); // only when health() lists `session.rewind`; pi does not
await s.restoreRewind(); // restores the removed path before the next prompt
await s.previews();
await s.participants(); // who can open the session
await s.reloadConfig({ refresh_repo: false });
await s.reloadConfigStream(
  { refresh_repo: false },
  (event) => event.type === "phase" && console.log(event.phase),
);

// The session verbs, bound to THIS session's own runtime (each provisions it first).
const { messages, hasMore } = await s.messages({ limit: 50 }); // { info, parts }[], oldest first
const { statuses, permissions, questions } = await s.pending();
await s.answerPermission(permissions[0].id, "once"); // "once" | "always" | "reject"
await s.answerQuestion(questions[0].id, [["Yes"]]); // null dismisses it
await s.compact(); // only when health() lists `session.compact`

Apps

kortix.project(projectId).apps deploys immutable App releases behind one stable URL: releases, machines per process group, persistent volumes. New Apps use private access. Apps is generally available: every project can create and deploy them.

const apps = kortix.project(projectId).apps;
const app = await apps.create({ slug: 'docs', name: 'Docs' });
const artifact = await apps.artifacts.uploadArchive(tarGzBytes);
await apps.deployments.create(app.app_id, {
  artifact_id: artifact.artifact_id,
  source: { kind: 'static', spa: true },
});
await apps.access.update(app.app_id, {
  mode: 'restricted',
  member_ids: [memberId],
  group_ids: [groupId],
});
const browserSession = await apps.access.session(app.app_id);

Access modes are private, project, restricted, public, and password. An access session exchanges a five-minute URL for an eight-hour, host-only cookie. A stopped or idle App resumes on the same public request. Transient machine requests receive 202 app_starting and Retry-After: 3.

Branch on app.capabilities (machines, scale, exec, volumes, snapshots, static, …). A call for a capability the App lacks answers 409 app_capability_unsupported. A backend (database, Convex) is an ordinary server App with a volume; there is no backend type.

apps.list() returns only the Apps the caller may open under each App's access policy (a project manager sees all), so viewer_can_access is true on every listed App. A get of an App the caller cannot open answers 404. Apps have no budget: account credits pay for App compute. An always-on App bills its machines 24/7; an on-demand App only while awake.

await apps.deployments.create(api.app_id, {
  artifact_id, source: { kind: 'oci_image', image, command: ['node', 'server.js'] },
  config: {                                              // the release config (kortix.yaml apps.<name>)
    processes: { app: '', worker: 'node worker.js' },
    http_service: { internal_port: 3000, access: 'app', processes: ['app'] },
    mounts: [{ source: 'data', destination: '/data', initial_size_gb: 10, processes: ['app'] }],
  },
});
const machines = await apps.machines.list(api.app_id);  // machine_id stays across releases
await apps.scale(api.app_id, { worker: 3 });            // 0-20 per group, applied in the background
const { exit_code, stdout } = await apps.exec(api.app_id, { command: ['ls', '/data'] });  // project.app.admin
const [data] = await apps.volumes.list(api.app_id);
const snap = await apps.volumes.snapshot(api.app_id, data.volume_id);
await apps.volumes.restore(api.app_id, data.volume_id, snap.snapshot_id);  // project.app.admin
await apps.update(site.app_id, { uses: ['api'], labels: ['frontend'] });    // the site may mint api tokens and bind to it
await apps.update(api.app_id, { display: { preview: 'none' } });           // its Apps-tab card shows an icon, not a live thumbnail

@kortix/sdk/react adds useAppMachines, useAppVolumes, and useAppVolumeSnapshots.

send() reads the persisted session model and agent before the first prompt on a handle. This prevents a snapshot-inherited runtime session from reusing stale snapshot defaults. A per-call choice overrides a setModel() or setAgent() choice. A handle choice overrides the persisted session default.

Saved session attachments

session.attachments.upload(file) stores up to 50 MiB in private object storage. It returns { attachment_id, filename, mime, size, url }. Use url in a file part sent to the prompt inbox. The API copies those bytes into the sandbox after startup. Uploads and session.attachments.read(attachment_id) do not start a sandbox. Reads return a Blob and require access to the session. Retries of the same File reuse the successful upload; an explicit attachmentId supports caller-managed retries.

Session labels and metadata

Every session carries labels: string[] and a free-form metadata object. Set both at project.sessions.create({ labels, metadata }). session.update({ labels }) replaces the labels; session.update({ metadata }) merges keys, and a null value removes a key. project.sessions.listPage({ labels }) returns only sessions that carry every given label, and useWorkspaceSessions(projectId, { labels }) does the same in React. Each label is 1–64 characters, at most 20 per session; one metadata write is at most 16,384 characters of JSON. Example: examples/12-session-labels.ts.

Agents working together

The session-scoped surface for messages between sessions, agent teams and workflow runs. The wire contract is @kortix/api-contract/multi-agent; the product docs are "Agents working together" in apps/web/content/docs/developers.mdx.

| Call | Route | Resolves | |---|---|---| | session.reachable({ scope }) | GET …/sessions/:sid/reachable?scope=mine\|project | ReachableAgents: { self, agents } | | session.peerMessages.send(body) | POST …/sessions/:sid/peer-messages (this session is the sender) | PeerMessageSendResult; 409 ambiguous_recipient throws ApiError with the candidates | | session.peerMessages.list({ direction }) | GET …/sessions/:sid/peer-messages?direction=in\|out | { messages } | | session.peerMessages.approve(id) · .deny(id) | POST …/peer-messages/:mid/approve\|deny (this session is the receiver) | PeerMessage | | session.setInbound(inbound) | PUT …/sessions/:sid/inbound | { inbound }: accept, hold or refuse | | session.tasks.list() · .create(body) · .update(seq, body) · .remove(seq) | …/sessions/:lead/tasks (this session is the lead; flag agent_teams) | SessionTask; a lost claim throws ApiError 409 task_taken | | session.workflows.list() · .control(runId, body) | the runtime's GET /kortix/runtime/workflows, POST …/workflows/:runId/control | { runs }; { ok, paused } |

React (@kortix/sdk/react): useReachableAgents(pid, sid, scope?), usePeerMessages(pid, sid, direction?), useTeamTasks(pid, leadSid), useSessionWorkflows(pid, sid), useTeammates(pid, leadSid). Session rows carry board_state, attention, last_activity_at, result_excerpt, agent_handle, team_lead_session_id and inbound.

Limits: 16,000 characters per message, 30 messages per minute per sender, a held message expires after 5 minutes.

Who wrote each message

kortix.session(projectId, sessionId).messageAuthors() (or getSessionMessageAuthors) returns { authors, initial_author }: authors maps a runtime message id to a SessionMessageAuthor, { kind: 'member', user_id, name, email, avatar_url } or { kind: 'session', session_id, name, agent? }. stops maps the user message that started a turn to the member who stopped that turn. In React, useSessionMessageAuthors(projectId, sessionId, messageCount) reads the same data.

Share one response

The web session's Share response button publishes one finished response. The prompt stays private unless the person selects Prompt and response. The SDK exposes the same operations as named exports:

import { getTurnShare, shareTurn, unshareTurn, getPublicResponse } from '@kortix/sdk';

const { share } = await shareTurn(projectId, sessionId, turnId, { include_prompt: false });
const response = await getPublicResponse(share.token, { backendUrl });
await getTurnShare(projectId, sessionId, turnId);
await unshareTurn(projectId, sessionId, turnId);

turnId is the user message id that starts the turn. Publishing requires a human owner or project manager who can read the session. Service accounts and agent credentials cannot publish. Sessions with personal connections or person-only secrets cannot publish. Repeating shareTurn updates the snapshot under the same URL. unshareTurn revokes it; anonymous reads then return 410. Snapshots exclude reasoning, tool calls, and attachments. The public page disables search indexing.

Who may stop a turn

In a shared session only the member who started the running turn, the session owner or a project manager may stop it. GET .../turn answers per caller in can_stop, and useSession().canStop reads it with no extra request: false means show no Stop and let a send queue. Any other stop answers 403 with code: 'not_turn_prompter'.

Which model answered a turn

kortix.session(projectId, sessionId).modelUsage() (or getSessionModelUsage) returns { latest, billed_cost, turns } from the gateway's request record. latest is { served_model, fallback_from, at, outcome } for the newest request, whatever its outcome: outcome is answered, client_aborted (the caller canceled it) or error; a cancel is not a model failure. served_model is the route id recorded for the request — the model that answered it, or on an unanswered request the model it was being served by. fallback_from is the routed model when the gateway rerouted after a failed attempt, whether or not the fallback answered. turns maps the runtime message id of each prompt to { served_models, fallback_from, billed_cost }. A transcript message carries only the model its turn asked for. In React, useSessionModelUsage(projectId, sessionId) reads the same record.

Agents that work together: messages, teams, workflows

A session can message another session, lead a team with a shared task list, and run workflows of many agents. Every call acts as the caller: a session may message another only where its person may prompt it, and a refusal is a result, never a silent drop.

const s = kortix.session(projectId, sessionId)
await s.reachable({ scope: 'mine' })            // { self, agents }: parent, children, lead, teammates
await s.peerMessages.send({ to: 'reviewer', message: 'The migration finished.', summary: 'Migration done' })
await s.peerMessages.list({ direction: 'in' })  // held messages wait for approve(id) / deny(id)
await s.setInbound('hold')                      // 'accept' | 'hold' | 'refuse'
await s.tasks.list()                            // the team this session leads (flag agent_teams)
await s.tasks.update(3, { status: 'completed' })
await s.workflows.list()                        // the runs on this session's own box
await s.workflows.control(runId, { action: 'pause' }) // 'resume' | 'stop' | 'restart_agent' + session_id

send answers { status, message, to, note }: delivered, held (the receiver's person approves it), refused, or dropped (a duplicate). A to that names several sessions is a 409 with the candidates. A session row carries board_state (working, needs_input, idle, completed, failed, stopped), attention, last_activity_at, result_excerpt, agent_handle, team_lead_session_id and inbound; listWorkspaceSessionsPage(projectId, { teamLead }) lists a lead's teammates. In React: useReachableAgents(pid, sid, scope?), usePeerMessages(pid, sid, direction?) (with approve and deny), useTeamTasks(pid, lead), useTeammates(pid, lead) and useSessionWorkflows(pid, sid) (with control). Each polls every 5 s while the page is visible, a running workflow every 2 s. A workflow poll never starts a stopped session.

React runtime

useSession(projectId, sessionId) opens the session runtime returned by POST /start. Every new session runs pi. The hook owns messages, rewind and restore, cancellation, commands, permissions, and questions. Hosts do not construct runtime routes. A feature the runtime lacks is a capability: gate its control with runtimeSupports(health?.capabilities, 'session.rewind'). pi lists session.subagents, session.compact, session.commands, session.steer and session.log.v2. It does not list session.rewind, session.fork, session.mcp, session.todo, session.shell, session.attach or session.config, so rewind, fork, MCP servers, the todo list, shell turns and the runtime config document stay hidden or empty on a pi session. The desktop is a property of the session's computer, not of its runtime: the daemon lists machine.desktop when the image installs it, and hasSessionDesktop(health?.capabilities) (or useHasSessionDesktop(), which is undefined until the runtime answers) gates a desktop control.

Every session saves its transcript at the end of each turn. useSession reads saved messages from the platform database while /start continues. It uses the server-validated runtime session id and lets the live read reconcile the saved messages by ID. A live read that returns no messages keeps the saved messages on screen: it means the computer lost its state, not that the conversation is empty. The first live read with messages reconciles them. Missing or rejected history falls back to the existing runtime path. An open reads the session log (GET …/log), on every host, with no project flag: useSession, readSavedSessionCopy (returned as log), useSessionSync with no mirror or with savedChild, and loadSessionExportMessages. The host applies a page it reads itself to the session store and renders useSessionLogThread. No hook and no SDK read sends GET …/transcript, whatever the log answers. A new session's 404 with code: 'session_log_missing' keeps the session on the log with nothing in it: the runtime's rows are the history. A failed read (a 5xx, the network, a 404 with no code, an invalid page) keeps the copy already on screen and reads the page again after 2 s, 10 s and 30 s, or after the API's Retry-After (at most 60 s). Either is read again at the next turn end and after a stream reconnect. Without a log in the store, useSession().loadOlder pages the runtime. An export with no page reads the runtime, and fails when the runtime has nothing. The session-open snapshot (GET …/snapshot) asks for no v1 window (?transcript=0). The v1 reads (getSessionTranscript, getSessionTranscriptSync, transcript, transcriptSync) are deprecated.

useSession().savedTranscript says whether that saved conversation can show before the computer wakes: loading while a saved copy may still arrive, shown once messages are in messages, and none when nothing can show until the runtime answers. A host renders placeholder rows on loading and its boot screen only on none. useSession().conversationEmpty is true when the saved copy proves the conversation empty (a complete read of the runtime found no messages), no turn ended since, and nothing is open or queued. A host renders the composer then, not a boot screen. useSession().bootImage is the sandbox image step of a provisioning box (/start's boot.image, a SessionStartImageStep): state is resolving, serving_last_ready, waiting_for_build, building, copying_to_region or waiting_for_provider, with image and since. A boot screen names that step. It is null outside the provisioning phase, once ready, and against an API that does not send it.

A host that registers a saved-copy store (setSavedCopyStore(createSavedCopyStore({ storage, userId }))) gets the kept copy painted before the first frame; the server's copy reconciles into it by message ID. createPersistedQueryCache does the same for accounts, projects and the paged session list. Both are per user and bounded; clear both on sign-out. Session states have one set of words for every host: sessionListStatus, SESSION_LIST_STATUS, sessionConnectionLabel, SESSION_NOTICE, and turnRetryLabel.

A server-rendered host can seed a known runtime session pin while /start runs:

useSession(projectId, sessionId, {
  initialRuntimeSessionId: persistedSession.runtime_session_id,
});

Use only a pin that the host authorized for the same (projectId, sessionId). The seed hydrates cached content. It does not choose the runtime identity. The pin returned by /start always replaces a stale seed. The SDK also scopes runtime query and synchronization controllers to the sandbox runtime. Two sandboxes cannot share browser cache state when a snapshot exposes the same runtime session id during adoption.

After a project replaces its repository, a session created before the replacement still starts. It runs the project's CURRENT config release and converges like any other session. What stays true of it is physical: its /workspace clone came from the old repository while origin now resolves to the new one. The two histories are unrelated, so a push from that clone needs a rebase first.

repositoryMode: 'previous' is accepted and changes nothing:

useSession(projectId, sessionId, { repositoryMode: 'previous' });

The server reads it as telemetry. Keep it only for callers built against the older behaviour.

Message retries keep the originating sandbox URL after navigation. A 404 or 410 message read stops automatic retries and preserves the cached transcript. An explicit reconciliation can recover the controller when the session returns.

Goals

A goal is what a person wants plus a definition of done (project flag goals, off by default). The API owns its state, pause, owner and verifier sessions, limits and verification; the goal's Drive folder holds the brief (GOAL.md), log.md and evidence/. Lifecycle fields written into GOAL.md change nothing.

import { createWorkspaceGoal, listWorkspaceGoals, pauseWorkspaceGoal, WorkspaceGoalConflictError } from '@kortix/sdk';

// goalId is the idempotency key: retry with the same id, get the same goal.
const { goal } = await createWorkspaceGoal(projectId, { outcome: 'EU billing live', goalId });

const [first] = await listWorkspaceGoals(projectId);
if (first?.capabilities.pause) {
  try {
    const { projectGoal } = await pauseWorkspaceGoal(projectId, first, 'Paused from my app.');
    console.log(projectGoal.revision); // the new revision
  } catch (error) {
    // Someone changed the goal first: error.goal is the goal as it is now.
    if (error instanceof WorkspaceGoalConflictError) console.log(error.goal.record.state);
    else throw error;
  }
}
  • Show a control only when goal.capabilities allows it. A read-only viewer gets every capability false.
  • criteria[].verified is set only by a passed independent verification; criteria[].reported_met is the agent's claim.
  • blockedReason says why a goal cannot go on (credits, wake_cap, session_cap, owner_lost, storage, flag_off, setup).
  • replyToWorkspaceGoal(projectId, goal, text, { answerId }) answers as the caller; one answer per answerId.
  • React: useWorkspaceGoals, useWorkspaceGoal, useWorkspaceGoalEvents, useWorkspaceGoalFiles, useSessionGoal, and useWorkspaceGoalControl (one request per double submit, the cache updated on success and on a 409).
  • Deprecated: WorkspaceGoal.heartbeat is always null; CreateWorkspaceGoalInput.sessionId is sent as the goal id.

The facade surface

createKortix(config) returns one client. The table below is illustrative, not exhaustive — see API-MAP.md for the full per-domain surface:

| namespace | what | |---|---| | kortix.workspaces | list (every workspace the caller can open, across accounts, one request) · get (role + state + settings) · update · create · delete · restore. See Workspaces | | kortix.workspace(id) | everything on kortix.project(id), except that get / update read and change the Workspace and tokens manages workspace keys (the project CLI tokens are cliTokens), plus delete · restore · setup · accountId · members.{list,invite,updateRole,remove,invites,cancelInvite,resendInvite} · leave · billing.state · iam.{assignments.{list,create,revoke},roles,groups,can,canBatch} | | kortix.projects | project-named calls, kept permanently: list · get · detail · create · provision · createWorkspace · update · archive · llmCatalog · modelPicker · sandboxTemplates · sessions (+ more: listForAccount, sandboxHealth, createSession) | | kortix.accounts | list · get · create · members · invites · secretResources.{list,create,rotate,retry,verify,remove,grant,revoke,setAccess} (a provider API key is checked against its provider on create and rotate; verify checks it again; the result is verification) · subscriptions.{list,usage} (project-authorized usage for named and legacy ChatGPT logins; reads credentials in place) · tokens.{list,create,revoke} (account-scoped CLI PATs, kortix_pat_…) · audit.{log,export,activity,webhooks.*} (filterable project/session reconstruction log; activity = who was active and how often, per UTC day) · branding.{get,update,uploadAsset,removeAsset,reset} (Enterprise organization branding: logo / icon / favicon, light + dark, product name) (+ more: updateName, leave, invite, removeMember, updateMemberRole) | | kortix.billing | entitlement/usage reads: accountState · accountStateMinimal · transactions · transactionsSummary · creditBreakdown · usageHistory · usageRollup · sessionCosts.{list,get} · tierConfigurations (with min_monthly_usd / max_monthly_usd) — plus a curated mutation surface: setPlan (subscribe to or change the credits plan; useSetBillingPlan() in @kortix/sdk/react) · subscription.{createPortalSession,cancel,reactivate} · credits.{purchase,redeem,autoTopupSettings,configureAutoTopup} (redeem({ code, accountId }) adds a credit code's amount to the non-expiring credits, once per account). checkout.{createSession,confirmSession}, subscription.{scheduleDowngrade,cancelScheduledChange,prorationPreview}, createPerSeatCheckout and claimPerSeatBilling are deprecated: they reject with ENDPOINT_RETIRED | | kortix.marketplace | public marketplace catalog browse + sources (not project-scoped): items · item · itemFile · marketplaces · featured · sources.{list,add,remove}. A project installs an item through createMarketplaceInstallSession(projectId, itemId), which starts a session whose agent merges the item into the project and opens a change request; it resolves { session_id } | | kortix.github | account-scoped GitHub App installs and repo linking: getInstallation · listInstallations · listLinkableInstallations (each entry carries linked_to_other_accounts, a count and never a tenant name) · listRepositories · listRepositoryBranches · linkInstallation · saveInstallation · deleteInstallation · linkRepository (source: 'managed' imports a repository the instance backend holds — self-host operator only, and mutually exclusive with installation_id) · replaceProjectRepository (changes an existing project's repository with an expected old URL; accepts a repository-scoped PAT or a temporary GitHub user proof for a repository-scoped App grant; can atomically copy selected shared runtime secrets from another project in the same account) | | kortix.gitBackend | the instance git backend ("Kortix managed", one per deployment, never an account connection): get() → {configured, kind: 'app'|'pat'|null, owner} (any authenticated user) · repositories({search?, limit?}) (self-host operator only; 403 otherwise) | | kortix.notifications | native push for a person's devices: registerDeviceToken · unregisterDeviceToken (Expo push tokens) · releaseDeviceToken(token, { backendUrl?, signal? }) (no token needed: a signed-out app removes its device token from a backend, POST /v1/notifications/device-token/release; resolves { ok: true }, a non-2xx answer throws ApiError with status). A session push reaches the phone of the person who created the session | | kortix.mobileClientPolicy() | the operator's mobile release policy, GET /v1/clients/mobile, no token needed (read before login): resolves { minimum_version, store_url_ios, store_url_android }, each a string or null; a non-200 answer throws ApiError. A mobile host compares its app version to minimum_version with compareSemver(a, b) (-1 \| 0 \| 1, or null when either side does not parse; ignores a leading v and -pre/+build suffixes) and fails open on null. Also exported standalone as getMobileClientPolicy and the MobileClientPolicy type. The operator sets MOBILE_MIN_VERSION, MOBILE_STORE_URL_IOS, MOBILE_STORE_URL_ANDROID on the API | | kortix.validateToken() | pasted-API-key validation helper — GET /me, never throws, resolves {valid, identity?, error?} | | kortix.connectors | Connector data plane for an agent-minted session token: catalog · tools · search · describe · call ({ account }) · accounts · uploadAttachment | | kortix.project(id) | id-bound handle: .apps (stable App URLs, access, artifacts, deployments, logs, rollback, start/stop, machines (machines.list/start/stop/restart/destroy, scale, exec), volumes (volumes.list/create/update/destroy/snapshots/snapshot/restore/fork), and token) · .secrets · .access · .connectors (data plane + configuration + Connections) · .policies · .triggers (cron / webhook / app; .fire(slug, { payload, dryRun }); run history .runs(slug) / .projectRuns({ status }); .replay(slug, runId)) · .files · .git · .changeRequests (incl. requestChanges) · .sessions · .tokens (project-scoped CLI PATs — the KORTIX_TOKEN shape) · .setupLinks.{requestSecret,requestConnector} (agent-minted secret-entry / connector links) · .validateManifest · .gitToken · .setDefaultAgent(name) · .session(sid) (+ more namespaces: .skills, .review, .approvals, .gateway (incl. .routing and .playground), .channels, .modelDefaults, .sandbox) | | kortix.session(pid, sid) | id-bound handle: lifecycle (get/update/delete/restore/start/restart/stop/reloadConfig/reloadConfigStream/setSharing/participants/setPinned/setArchived/presence/previews/commit/publicShares/ensureReady) · finalized cost() · send/abort/rewind/restoreRewind (needs session.rewind)/setModel/setAgent · transcript() · .files · runtime URL helpers (health/previewUrl/proxyUrl) · the session verbs (messages/pending/answerPermission/answerQuestion/compact) · stream() · the deprecated .runtime | | kortix.runtime() | the runtime REST compatibility client of the process-wide active sandbox. It throws on a scoped client. Use the session verbs on kortix.session(pid, sid) instead |

Runnable, self-contained scripts for the highest-value flows live in examples/: list projects with a PAT, send + stream, the multi-tenant server-wrapper pattern, headless transcript rendering, cost pass-through / re-billing, and session files + project secrets. Each file's header comment states the env vars and the exact bun run examples/….ts invocation.

Wrapper backends can attach bounded, non-secret scalar context when creating a session. It is persisted across cold recovery/replacement restart and exposed to the agent only as one KORTIX_SESSION_CONTEXT JSON envelope:

await kortix.project(projectId).sessions.create({
  runtime_context: { workspace_id: "org_123", locale: "de" },
});

Do not put credentials in this map. For a white-label/backend wrapper, give each end user their own project: a project-owned connection is reachable by every session in its project, so the project is the isolation boundary. Create the connection in that user's project, store its credential through the dedicated credential endpoint, and pass only the non-secret connection id at session create:

// One project per end user: never put two end users' credentials in one project.
const project = kortix.project(projectIdOfThisEndUser);
const connection = await project.connectors.connections.reconcile({
  connector_alias: "customer-data",
  owner_type: "project",
  label: "Customer data",
  metadata: { tenant_ref: wrapperTenantReference },
});

// Omit `auth` when creating to apply source-advertised authentication.
const auth = await project.connectors.auth.discover({
  slug: "hubspot",
  provider: "postman",
  spec: "https://github.com/HubSpot/HubSpot-public-api-spec-collection",
});
await project.connectors.connections.updateCredential(connection.connection_id, {
  value: shortLivedCapability,
  kind: "secret",
});
await project.sessions.create({
  runtime_context: { locale: "de" },
  connector_bindings: {
    "customer-data": { connection_id: connection.connection_id },
  },
});

For bring-your-own authorization, each logged-in member creates their own connection without supplying an owner id; Kortix derives ownership from the bearer token:

const connection = await project.connectors.connections.reconcileMember({
  connector_alias: "gmail",
  label: "My Gmail",
});
await project.connectors.connections.pipedreamConnect(connection.connection_id);
// Complete OAuth, then:
await project.connectors.connections.pipedreamFinalize(connection.connection_id);
await project.sessions.create({
  connector_bindings: { gmail: { connection_id: connection.connection_id } },
});

Member connections are owner-only even for project managers, and sessions using one must remain private. A service account — an agent or a trigger — never reaches a member connection, only the shared project one. Project defaults remain shared. A project-owned connection is reachable by every session in the project, and any of them may bind it by id, so it must never hold one end user's credential in a project other end users' sessions run in: give each end user their own project instead. The owner types external, agent and subject are retired: the API refuses to create them (409 CONNECTOR_CONNECTION_OWNER_NOT_REACHABLE) and no session reaches an existing one. They stay in the ConnectionOwnerType union so existing code compiles. Every connection is project/connector scoped and resolved on every Connector request, so revocation takes effect without a restart. Credentials are encrypted server-side and are never returned, placed in KORTIX_SESSION_CONTEXT, or injected into the sandbox environment. Raw env and MCP configuration are not session-create inputs.

session.stream() is a thin facade over the framework-free openEventStream primitive (also exported directly, for hosts that want to manage the client themselves): it resolves THIS handle's own runtime (ensureReady()), connects to that runtime's SSE endpoint, and hands you a close()-able handle. No React required — safe to call from a server-side "Kortix as a Backend" wrapper (Node/Bun), a worker, or a CLI:

const handle = await kortix.session(pid, sid).stream({
  onEvent: (event) => console.log(event.type, event),
  onGapRehydrate: (gapMs) => console.warn(`reconnected after a ${gapMs}ms gap`),
});
// later, to stop:
handle.close();

session.stream() emits the runtime's events (message.updated, message.part.updated, session.status, permission.*, question.*, …), the same for every session. Use useSession() in React.

Events arrive in batches, 16 ms apart. Consecutive message.part.delta events for one part field in a batch arrive as one event: properties.delta is their text joined in order, id is the last event's id, and coalesced lists the events it replaced.

@kortix/sdk/react's useRuntimeEventStream uses the exact same primitive under the hood — it just also writes into the React Query cache.

Session log types

The root entry exports the types of the Kortix session log, kortix.session/2 minor 1: SessionLog, SessionLogThread, SessionLogMessage and SessionLogBlock, the block variants TextBlock, ReasoningBlock, AttachmentBlock, ToolCallBlock, CompactionBlock, SubtaskBlock, StepBlock and HarnessBlock, the grade types (RestoreGrade, GradeCounts), and the constants SESSION_LOG_SCHEMA, SESSION_LOG_MINOR and KNOWN_TOOL_KINDS. They are types and constants only: this package has no zod dependency. The validators (SessionLogSchema and the rest) stay in the private @kortix/api-contract package. The types are a copy of the api-contract session-log types, and a test fails when the two differ. See the reference.

@kortix/sdk/react adds useSessionLog(session) and useSessionLogThread(session, threadId?, { throttleMs? }): the log of a session useSession owns. useSessionLog marks its source: 'v2' is the API's log, which every open reads first when the API holds a log for the session. No project flag is read. Until a session's log answers a page, the hooks return source: 'v1': the fromV1 conversion of the runtime's v1 rows, with the turn that runs kept open. Pass the session's runtimeSessionId; without it, or before the first row, they return null. throttleMs paces the v1 rows as useSessionMessages paces them. The v1 hooks (useSessionMessages, useSession().messages) return the same rows either way. useSessionLogOlder(session, threadId?) pages one thread of a v2 session back (GET …/log?thread=&before_seq=). See the reference.

useSessionView(projectId, sessionId, { enabled?, throttleMs? }) is a read-only view of any session from the API alone: its log (GET …/log) and the session stream on the v2 schema, live. It sends no POST /start, so it does not wake the session's computer, and it does not point the app's runtime at the session's box. Mount it beside the session useSession owns: a sub-agent's dialog over its lead's chat, a board preview. status is loading, ready (the root thread has messages), empty (no log yet, or no message) or error (the read failed; it is read again after 2 s, 10 s and 30 s, and retry() reads it now). thread is the root thread; hasOlder, isLoadingOlder and loadOlder page it back. Files, previews and a terminal need the box: open the session for them.

Three transitional functions bridge a host that renders blocks while its rows are still v1:

  • toolCallBlock(part): the ToolCallBlock of a v1 tool part, the block the v1 conversion builds for it. An open call stays open; a call that streams its input has the text so far as its input. One block per part object. It goes away in the SDK major that removes the v1 rows.
  • toolCallMetadata(call): the harness's display metadata of a tool call, the whole state.metadata of the v1 part it came from (bash command, read loaded, task sessionId, edit filediff); for a record pi wrote natively, its details. It stays until the session-log schema carries display metadata of its own.
  • sessionLogOfV1Rows(sessionId, runtimeSessionId, rows): the v1 live view of one runtime session's v1 rows, the log useSessionLogThread returns on v1, for rows a host reads without a useSession (a sub-agent's chat). One message per row object, kept while the row is unchanged. It goes away with the v1 rows.

A host draws a ToolCallBlock with toolCallStatus(call) (completed, not complete), toolCallRawOutput(call), toolCallError(call) (the semantic error, else the result text) and, for a sub-agent row, subAgentCalls(thread).

An image_gen card reads imageGenView(call, live?): an ImageGenView with phase (ImageGenPhase: generating, ready, failed, stopped), action, prompts, aspectRatio (the requested ratio, fixed across phases), startedAt, model, provider, error (message, suggestion?, code?, retryAfterSeconds?, connectModel) and images: one frame per image (prompt, phase, aspectRatio, imagePath, directUrl, error), one to four per call. imageGenRetryDelay(seconds) and formatImageGenElapsed(ms) format its retry wait and elapsed time. imagePathsShownByImageGen(blocks), withoutImageGenDuplicates(items, paths, digests?) and showDrawnByImageGen(call, paths, digests?) keep a show from drawing an image the turn's image_gen card already drew, by path or by the bytes' sha256 (imageDigestsShownByImageGen(blocks), a frame's sha256?, showItemDigest(call, path) for a show output). Consecutive image_gen cards of a turn draw as one grid: sessionLogImageGenRuns(segments, drawn) finds the runs and imageGenGrid(views) lays out their tiles and rows. createImageCache(options) is a framework-free store of loaded images by path and sha256, for a host that shares one loaded image across surfaces. See the reference.

getSessionLogPage(projectId, sessionId, query?), or kortix.session(p, s).log(query?), reads one page of the stored session log (SessionLogPage: the session record, every thread head, a thread's newest messages by seq, has_more). query is { thread?, before_seq?, limit? } (SessionLogPageQuery). It answers while the sandbox is stopped, and is null when the session has no stored log yet. findSessionAttachments reads session-log records as well as v1 messages.

toToolView, getChildSessionId, partOutcome, contextToolTrigger and shouldShowToolPart accept a ToolCallBlock as well as a v1 tool part.

Block turns

The root entry also exports the turn model over session-log blocks, the one a transcript renders a thread from. It replaces the v1 turn helpers over MessageWithParts (groupMessagesIntoTurns, collectTurnParts, getTurnStatus, getTurnError, getTurnCost, findLastTextPart, getShellModePart, isAttachment, segmentTurn, groupShowSegments, splitTurnWork, mergeBurstSteps, compactionTurnInfo, resolveWorkingTurn, hasRetryingAssistantTurn), which stay until the next major.

  • sessionLogTurns(thread, options?) returns one SessionLogTurn per user message: user, assistant (the replies, by reply_to then order), blocks, and the derived fields status, error, cost, compaction, threads, plan, shell, response, attachments, live, queued and retrying, computed once. Options: busy (the session works), liveMessageId (the working projection's turn), held (the prompts the inbox still holds, in queue order), threads (the session's threads, for threads), pricing. A turn keeps its identity while its messages and derived fields do. A message out of context (a failed attempt) keeps its turn; hideOutOfContext: true leaves out every message with in_context: false that is not streaming.
  • sessionLogTurnSegments(blocks, options?), splitSessionLogTurnWork(segments, { working }) and sessionLogBurstSteps(blocks) draw a turn: bursts, text and standalone calls, the settled-turn fold, and the rows of a burst. The deliverables (show, show_user, image_gen: NO_GROUP_ACTIVITY_TOOLS) stand alone and are hoisted above the final text.
  • sessionLogCallStatus(call, live) is the status a call renders with: an open call, or one the closure rule closed, is running or pending on a live turn and interrupted on any other. sessionLogOpenCall(call) is the call in that open shape (a closure-rule call reopened, without its result), so the v1 live view and the v2 page render one call the same.
  • sessionLogBurstSummary(blocks) counts a burst's steps (total, failed, completed, hasPlumbing); sessionLogBurstRunning(blocks, { working, trailing? }) says whether it is still in progress.
  • sessionLogShowGroupCall(calls) is a show-group segment as one show call whose input.items is the carousel. sessionLogAnsweredQuestionCall(call, answers, input?) is a question call with answers the harness has not confirmed yet.
  • sessionLogActivityText(turn, childMessages?) is the activity line of a live turn.
  • sessionLogRetrying(thread) says whether the thread's last reply retries a provider error (a turn's retrying), without grouping turns: the busy state a host passes as busy reads it. freshSendHint and fallbackBusyRowAfterTurnId accept block turns as well as v1 turns, and stepLabel any session-log block.
  • classifySessionLogMessage(message) is classifyTurn of one session-log message: its blocks as ClassifiedParts and its error with the gateway's own sentence. A host whose renderers take ClassifiedParts renders a thread through it until the v1 helpers go.
  • sessionLogPromptMessage(prompt, threadId) is the user message of a queued inbox prompt the thread has not placed; append it and name it in held.
  • sessionLogThreadMetrics(thread, { pricing? }) is one thread's cost (every reply's usage priced: its reported cost, else the lookup), messages (all, user, assistant: a compaction counts as its marker and, once landed, its summary reply), tokens (every priced reply's usage summed) and context (the last reply with tokens: its usage is the context size): what a context modal's overview and its sub-agent rows show.
  • A pending permission or question matches a call by call.call_id (getPermissionForTool, getQuestionForTool).
  • A session's history: isSessionLogRestoreNote(message) is the note a partial restore leaves for the agent (render it as a notice, not a prompt); sessionLogRestoreNotice(session) is what that restore could not bring back ({ generation, missing: [{ kind, count }] }, null unless the grade is partial and a restore wrote the record); sessionLogHarnessSwitches(history, turns) maps each switch harness.history records to the turn it lands on.
  • Texts stored by reference: sessionLogReferencedTexts(messages) lists every text the API kept as a 16 kB preview ({ ref, bytes }); read one with fetchSessionAttachment(ref) and put it back with sessionLogWithFullTexts(thread, texts). sessionLogFullTextAction(refs) says 'download' above SESSION_LOG_FULL_TEXT_INLINE_MAX_BYTES (1 MiB) in all, else 'inline'; sessionLogFullTextPlan(refs) says which texts: the smallest fill the 1 MiB inline budget, only the rest download.
  • useSessionLogRecord(session) (@kortix/sdk/react) is the v2 session record without threads (restore_grade, grade_counts, harness, ext); it keeps its identity through message puts.

Both layers give one answer for a record converted from v1 (its cost too: a v1-derived reply is priced over its v1 parts): the SDK's test suite compares them on every golden fixture.

Kortix as a Backend (server-side)

createKortix() stores its config — crucially, the bearer-token getter — in a process-wide singleton. That's correct for a host with one config for its whole lifetime (a browser tab, a CLI, a single-tenant server), but unsafe for a server process handling concurrent requests for different end users: two in-flight requests racing through createKortix()/configureKortix() with different tokens clobber each other, and the last write wins for every other in-flight request.

@kortix/sdk/server (Node/Bun only — never import it from a browser bundle; it statically imports node:async_hooks) fixes this with AsyncLocalStorage:

import { createScopedKortix } from "@kortix/sdk/server";

// Express/Hono/Bun.serve — any per-request handler. One scoped client PER
// REQUEST; each end user's token stays isolated to that request's own async
// call tree, even across `await`s, even under concurrency.
app.get("/projects", async (req, res) => {
  const kortix = createScopedKortix({
    backendUrl: process.env.KORTIX_API_URL!,
    getToken: async () => resolveKortixTokenFor(req), // per-end-user PAT/token
  });
  res.json(await kortix.projects.list());
});

createScopedKortix(config) has the same shape as createKortix(config) — every method call (including calls through .project(id) / .session(pid, sid) handles minted at call time) automatically runs inside that config's scope, and it never writes the process-global singleton. For middleware-style wrapping of an entire request body instead, use the lower-level primitive:

import { runWithKortix } from "@kortix/sdk/server";

app.use(async (req, res, next) => {
  await runWithKortix(
    { backendUrl, getToken: async () => resolveKortixTokenFor(req) },
    async () => {
      await next(); // every Kortix call anywhere in this request sees THIS config
    },
  );
});

A runnable version of the pattern is examples/03-server-wrapper.ts, and the full production-shaped reference is apps/white-label/lumen (Lumen): its own users and orgs (Convex + Better Auth), one Kortix project shared by every customer firm (a Drive folder, an agent and a service account per firm), the firm's service-account secret held server-side, and a same-origin proxy built on forwardKortixRequest — see its README.

Rendering chat (the headless chat kit)

For the Kortix chat itself, use SessionThread from @kortix/ui/session (the Kortix component package): one component renders the whole session from one useSession(projectId, sessionId) result, the same thread apps/web renders. ToolRegistry.register(name, Row) replaces one tool row. Docs: https://kortix.com/docs/#the-thread-alone. The kit below is for a host that draws every part with its own components.

Everything needed to render an agent transcript without adopting any Kortix UI: classifyPart/classifyTurn (framework-free, from the root entry) normalize all twelve part types of the Kortix transcript format (kortix.transcript.v1, the same for every session) (text, reasoning, tool, file, subtask, patch, snapshot, agent, retry, compaction, step, + a forward-compat unknown) into a typed ClassifiedPart, and normalize a failed assistant turn's info.error into a { name, message } TurnError — so "assistant message with zero parts but an error" renders as a failure, not silence. renderParts (@kortix/sdk/react, though it has no React import) requires a renderer for every part kind at compile time, so a new part type is a build error at your call site instead of a silent drop in production:

import { renderParts, type PartRenderers } from "@kortix/sdk/react";
import { classifyTurn } from "@kortix/sdk";

const renderers: PartRenderers<React.ReactNode> = {
  text: (p) => <Markdown>{p.text}</Markdown>,
  reasoning: (p) => <Thinking text={p.text} />,
  tool: (p) => <ToolCard name={p.tool.name} status={p.tool.status} />,
  file: (p) => <Attachment name={p.filename ?? p.url} />,
  subtask: (p) => <Delegated agent={p.agent} />,
  patch: (p) => <DiffStat files={p.fileCount} />,
  retry: (p) => <Note>{`retry