Machine
SDK

Sessions

Run a session, stream its events, and handle the errors it can throw.

GithubEdit

A session is one agent run, in its own sandbox, on its own git branch. kortix.session(projectId, sessionId) returns the handle for everything a session does: start it, send prompts, stream events, and read status. This page covers the handle, the readiness handshake, streaming, and the typed errors an SDK call can throw.

typescript
const s = kortix.session(projectId, sessionId);

s is the handle for everything a session does. The session ID, the sandbox ID, and the branch name are the same value. See Sessions for the concept.

Session lifecycle

MethodWrapsWhat it does
s.get(opts?)GET /projects/:pid/sessions/:sidReads session details
s.update(input)PATCH …/sessions/:sidRenames the session or updates metadata
s.start(waitMs?)POST …/sessions/:sid/startProvisions and boots the runtime
s.restart()POST …/sessions/:sid/restartRestarts the runtime; keeps the same sandbox
s.reloadConfig(input?)POST …/sessions/:sid/reloadRecompiles agent config and replaces the runtime after validation
s.reloadConfigStream(input, onEvent)POST …/sessions/:sid/reload-streamRuns the same reload and emits server-confirmed progress phases
s.stop()POST …/sessions/:sid/stopStops the runtime; the session stays
s.delete()DELETE …/sessions/:sidDeletes the session
s.setSharing(intent)PUT …/sharingSets sharing and visibility
s.cost()GET /usage/session-costs/:sidReads finalized LLM and compute cost without starting the runtime
s.scope()GET …/sessions/:sid/scopeReads stored secret narrowing and materialized connection bindings
s.rescope(input)PUT …/sessions/:sid/scopeReplaces supplied scope fields for the next prompt or tool call
s.commit(input?)Commits the agent's work

s.delete() deletes the session and its runtime. This cannot be undone. To pause a session without losing it, call s.stop() instead.

Use the streamed method when the caller displays reload progress:

typescript
await s.reloadConfigStream({ refresh_repo: false }, (event) => {
  if (event.type === 'phase') console.log(event.phase);
});

The phases are checking-session, refreshing-workspace, compiling-config, applying-config, and confirming-config. The server omits refreshing-workspace when refresh_repo is false. The applying-config phase includes the daemon's validated runtime replacement.

Three more read methods round out the handle:

  • s.previews() — candidate preview ports the runtime exposes.
  • s.publicShares.list() / .create(input) / .revoke(shareId) — public share links.
  • s.audit(limit?) — the session's audit trail of agent actions.
  • s.transcript(options?) — a compact server-side transcript (text and tool calls, no tool inputs or outputs). This works with a project-scoped session token.
  • s.voiceTranscript(options?) — this session's live voice-call transcript (spoken turns plus ask_kortix/run_command worker tool calls). Returns an empty list when the session has no live call, not a 404.

Readiness is a handshake

Before you send a prompt, call ensureReady(). It provisions the sandbox if needed, waits for the runtime to boot, and returns the resolved runtime.

typescript
const { opencodeSessionId, runtimeUrl, sandboxId } = await s.ensureReady();

On a cold boot, ensureReady() can throw RUNTIME_UNAVAILABLE. See Retry on a cold boot for what that means and how to retry.

s.send() and s.abort() call ensureReady() for you.

Seed a server-authorized OpenCode pin

A server-rendered React host can supply the OpenCode pin already persisted for the same Kortix session:

tsx
const session = useSession(projectId, sessionId, {
  initialOpenCodeSessionId: persistedSession.opencode_session_id,
});

The seed only hydrates cached transcript content while /start runs. It does not override the runtime identity. The pin returned by /start is authoritative and replaces a stale seed.

Do not accept this value from an untrusted tenant selector. Do not create an OpenCode session in the host. Kortix creates and persists the root session. OpenCode query caches and transcript controllers are scoped to the sandbox runtime, so equal OpenCode ids from different sandboxes do not share cache entries.

Send a prompt

typescript
s.setModel({ providerID, modelID }); // sticky for later send() calls
s.setAgent('build'); // sticky for later send() calls

await s.send('Refactor the auth module');
await s.send('One-off task', { model, agent }); // overrides for this call only
await s.abort(); // stop the current run

For OpenCode REST sessions, the first send() on a handle reads the model and agent persisted on the Kortix session. This prevents a snapshot-inherited OpenCode session from reusing stale snapshot defaults.

Prompt choice precedence is:

  1. The send() call.
  2. The handle's setModel() or setAgent() value.
  3. The persisted Kortix session default.

setModel only chooses what the next local send asks for — it never leaves the handle. To persist a new model for a running session server-side, use changeModel:

typescript
const { applied_live } = await s.changeModel('anthropic/claude-opus-4-8');

Restarting the runtime is how the change takes effect, so an in-flight turn ends. applied_live is true when a running session took it now, false when it applies at the next start. Only the session owner or a project manager may change the model; anyone else gets 403.

send() resolves the runtime, then prompts it. abort() stops the current run without deleting the session.

Session scope and cost

Read the stored secret narrowing and materialized connection bindings. secrets_allowlist: null means the agent's secret grant applies:

typescript
const scope = await s.scope();
scope.connector_bindings_configured; // false = inherits the project defaults

connector_bindings is the RESOLVED map, so it looks the same for a session that overrode its connectors and one that inherits the project defaults. Read connector_bindings_configured to tell them apart before rendering the scope or sending it back.

Replace one or both scope fields:

typescript
await s.rescope({
  secrets: ['DATABASE_URL'],
  connector_bindings: {
    github: { connection_id: connectionId },
  },
});

Each supplied field replaces its complete previous value. Omit a field to leave it unchanged. Connection changes apply to the next tool call. Secret removal stops future delivery but cannot remove an already disclosed value from model context or an existing process.

Both axes have an explicit way back to the default. They are not the same as an empty value:

typescript
await s.rescope({
  secrets: null, // inherit the agent's secret grant
  connector_bindings: null, // drop the override; inherit the project defaults
});

secrets: [] and connector_bindings: {} are the opposite instruction: an explicit "no project secrets" and "no connectors at all", project defaults included. A session that sends {} where it meant null fails closed on every alias it did not name.

Read the unified cost record:

typescript
const cost = await s.cost();

The record combines finalized LLM cost, billed sandbox compute cost, model usage, token totals, compute duration, and ledger entries. s.cost() does not call ensureReady().

Runtime status and previews

MethodReturnsUse
s.health(init?){ status, ok, health, body }Check whether the runtime is alive
s.previewUrl(port, path?)stringGet a proxy URL for a port the agent exposed
s.proxyUrl(url?)string | undefinedRewrite a localhost URL the agent printed
typescript
const { ok, health } = await s.health();
const url = s.previewUrl(3000, '/docs');

s.health() never throws. Call it any time, even before the session has a runtime. s.previewUrl() and s.proxyUrl() need a resolved runtime — call s.ensureReady() first, or they throw SessionNotReadyError. See Session readiness errors.

Streaming

Use s.stream() to receive live events in a script or server. In a React app, use useSession instead — it manages the whole session lifecycle for you.

s.stream() is the OpenCode REST compatibility event stream. The Kortix API proxies it from the sandbox. There is no separate WebSocket endpoint. The transport is fetch with a streaming response body, read through ReadableStream and TextDecoderStream. The SDK handles reconnection, backoff, and a heartbeat check.

Stream a session:

  1. Call ensureReady() first. The runtime does not exist until the sandbox starts.
  2. Open the stream before you send a message, so you do not miss early events.
  3. Send the message.
  4. Close the stream when you see session.idle.
typescript
const session = kortix.session(projectId, sessionId);
const { opencodeSessionId } = await session.ensureReady();

const stream = await session.stream({
  onEvent: (event) => {
    if (event.type === 'session.idle' && event.properties.sessionID === opencodeSessionId) {
      onTurnDone();
      stream.close();
    }
  },
});

await session.send('Refactor the auth module');

Streaming needs fetch with a real ReadableStream body and TextDecoderStream. Browsers, Node 18 and later, Bun, and Cloudflare Workers all support it. React Native and Expo do not: their fetch has no response.body. On React Native, use createHttpSessionSyncController for bounded history and status synchronization. Use a platform-specific event transport for live events.

The controller loads the newest 10 messages first. loadOlder() follows the server cursor. loadHttpSessionHistory() follows every cursor for explicit exports.

Event types

Each event has a type and a properties object that holds its data, for example event.properties.sessionID.

typeWhen it fires
message.updated / message.removedA message changed or was deleted.
message.part.updated / message.part.removedA part (text, tool call, file) grew or was removed.
session.statusThe session's busy state changed.
session.idleThe turn finished.
session.errorThe turn failed. The event carries the error.
question.askedThe agent asked for input.
question.replied / question.rejectedThe answer to a question arrived.

Turn raw messages and parts into renderable output with classifyTurn. See SDK reference.

Retry on a cold boot

ensureReady() polls the session's /start endpoint — each call long-polls up to 30 s — until the runtime reaches a terminal ready/failed/stopped stage or its deadline (readyTimeoutMs, default ~180 s) elapses. On a warm session the first poll resolves ready immediately. On a cold boot it keeps polling while the sandbox reports retriable: true, so a slow start just takes longer rather than throwing. It only throws an ApiError with code: 'RUNTIME_UNAVAILABLE' if the runtime is still not ready when the deadline expires.

ensureReady() is idempotent, so concurrent calls for the same session share one /start request instead of sending several. The retryUntilReady helper below is now optional — ensureReady() already retries internally — but stays useful if you want a longer total budget than the default readyTimeoutMs.

typescript
async function retryUntilReady<T>(ensure: () => Promise<T>): Promise<T> {
  const deadline = Date.now() + 300_000;
  for (;;) {
    try {
      return await ensure();
    } catch (error) {
      const provisioning = error instanceof ApiError && error.code === 'RUNTIME_UNAVAILABLE';
      if (!provisioning || Date.now() > deadline) throw error;
      await new Promise((r) => setTimeout(r, 3_000));
    }
  }
}

See Error classes for the full ApiError shape. In React, useSession retries /start for you, so you do not need this pattern.

Files

s.files reads and writes the session's sandbox: list, read, readBlob, status, findFiles, findText, upload, create, copy, remove, mkdir, rename. Every call resolves the runtime first, and always targets this session's own sandbox. See the SDK reference for the full method list.

The raw runtime

s.runtime is the typed OpenCode REST client. Use it only for calls that send, abort, and stream do not cover. It requires a resolved OpenCode runtime — call s.ensureReady() first.

typescript
const { opencodeSessionId } = await s.ensureReady();
await s.runtime.session.prompt({
  sessionID: opencodeSessionId,
  parts: [{ type: 'text', text: 'Refactor the auth module' }],
});

The OpenCode sessionID here is not the session ID you pass to kortix.session(projectId, sessionId). The SDK resolves it during ensureReady() and caches it on the handle.

Warm a project session

Call ensureWarm() when a project landing page needs one runtime ready before the first prompt.

typescript
const project = kortix.project(projectId);
const warm = await project.sessions.ensureWarm();

// An ORDINARY session. Prompt it like any other.
await kortix.session(projectId, warm.session.session_id).send("Build me a widget");

ensureWarm() creates, or returns, one unused session for the current user. It is the same create sessions.create() runs, with the project's defaults: same billing gate, same concurrent-session cap, same connector requirements. The only difference is metadata.warm, which hides the session from sessions.list() until its first prompt lands.

Treat it as speculative. A 409 WARM_SESSION_UNAVAILABLE means the account has no concurrent-session headroom to spare or the project cannot be warmed right now — fall through to sessions.create(), which reports the real reason.

The warm session carries the project's DEFAULT agent and sandbox. If the user picks a different one, abandon it and call sessions.create(): an unused warm session is hidden and reaped on its own.

claimWarm() is deprecated. A warm session is an ordinary session, so there is nothing to claim — navigate to it and prompt it. The call still works for consumers pinned to the older shape and is removed in the next major.

Handling errors

Every call through createKortix rejects with a typed Error subclass, never a plain object. Catch the error, check instanceof, and branch on .status or .code.

typescript
import { ApiError, BillingError } from '@kortix/sdk';

try {
  await kortix.project(projectId).sessions.create();
} catch (err) {
  if (err instanceof BillingError) {
    // 402 — out of credits or over a plan limit
  } else if (err instanceof ApiError) {
    // any other failed request — err.status, err.code, err.detail
  } else {
    throw err;
  }
}

Error classes

ClassExtendsWhen it throwsKey fields
ApiErrorErrorDefault for any failed request: bad status, network failure, timeout, or abortstatus, code, detail, response, url, endpoint, timeout
AuthErrorApiErrorgetToken returned null. Kortix never sent the requestcode is always 'NO_SESSION'
BillingErrorErrorHTTP 402. The only billing error classstatus (402), detail.message
RequestTooLargeErrorErrorHTTP 431. Usually too many files in one requestdetail.suggestion
SessionNotReadyErrorErrorA runtime accessor ran before ensureReady()name is 'SessionNotReadyError'

ApiError.name is 'ApiError' by default. Two cases override it:

  • name: 'AbortError', code: 'ABORTED' — the request was cancelled, for example by navigation. This is not a failure. Ignore it.
  • code: 'TIMEOUT' — the request's own timeout elapsed. url, endpoint, and timeout show what timed out.

For any other failure, status holds the HTTP status code. code comes from the backend's error_code, or falls back to the status as a string. message is an enumerable own property on ApiError, so it survives JSON.stringify and object spread.

Kortix retries some requests before your code sees an error. If a GET or HEAD request returns 502, 503, or 504, Kortix retries it up to 2 times, with a 250ms then 500ms delay. A transient transport failure on a GET or HEAD — a network error, not a status code — is retried the same way. A retry that succeeds never reaches onError. Kortix never retries POST, PUT, PATCH, or DELETE requests, or a 500 response.

Kortix throws AuthError on the client, before it sends a request, when getToken() returns null. AuthError extends ApiError, so err instanceof ApiError still matches. Check err instanceof AuthError, or err.code === 'NO_SESSION', to treat "not signed in" as a separate case from a backend failure.

Kortix throws BillingError for every HTTP 402 response: out of credits, over a plan limit, or another billing gate. detail.message holds the reason from the backend.

Kortix throws RequestTooLargeError for HTTP 431. This usually means the request carried too many files. detail.suggestion holds a ready-to-show hint for the user.

Session readiness errors

Two errors mean the session's sandbox is not ready yet. Handle each one differently.

SessionNotReadyError throws synchronously when you call a runtime accessor — session.previewUrl(), session.proxyUrl(), or session.runtime — before this session handle has resolved its sandbox. A session handle only resolves its own sandbox; it never falls back to another session's sandbox.

typescript
import { SessionNotReadyError } from '@kortix/sdk';

const s = kortix.session(projectId, sessionId);
try {
  const url = s.previewUrl(3000); // throws: not resolved yet
} catch (err) {
  if (err instanceof SessionNotReadyError) {
    await s.ensureReady();
  }
}

Call await session.ensureReady() first, or call send(), which readies the session internally. session.health() is the one accessor that never throws this error, so you can poll it before the session boots.

RUNTIME_UNAVAILABLE is the second error — it means ensureReady() itself timed out waiting for a cold boot. See Retry on a cold boot for the full pattern. In React, useSession retries this for you and exposes it through the phase value instead of throwing.

Helpers

HelperSignatureWhat it does
parseBillingError(error)(error) => ErrorWraps a 402 response into a BillingError. Returns other errors unchanged
isBillingError(error)(error) => booleanReturns error instanceof BillingError
formatBillingErrorForUI(error)(error) => BillingErrorUI | nullReturns null for non-billing errors. Otherwise returns { alertTitle, alertSubtitle } for an upgrade modal
typescript
import { formatBillingErrorForUI } from '@kortix/sdk';

try {
  await kortix.session(projectId, sessionId).start();
} catch (err) {
  const ui = formatBillingErrorForUI(err);
  if (ui) showUpgradeModal(ui.alertTitle, ui.alertSubtitle);
}

In @kortix/sdk/react

@kortix/sdk/react re-exports BillingError, RequestTooLargeError, parseBillingError, isBillingError, and formatBillingErrorForUI. It does not re-export ApiError or AuthError — import those from @kortix/sdk.

useSession classifies every send, answerQuestion, answerPermission, and rejectQuestion failure into one sendError object, so you do not need to write instanceof checks by hand:

typescript
interface KortixSendError {
  kind: 'billing' | 'runtime-not-ready' | 'runtime-error';
  message: string;
  billing?: BillingError; // set when kind is 'billing'
  cause: unknown;
}
tsx
const s = useSession(projectId, sessionId);

if (s.sendError?.kind === 'billing') {
  const ui = formatBillingErrorForUI(s.sendError.billing);
}

See React hooks for the rest of useSession.

On this page