MachineMachine
MachineMachine
MachineGetting startedQuickstartAccounts & access
Your project
AgentsLegacy kortix.tomlManifest referenceModelsAgent permissionsSecrets
Running work
Change requestsRuntime & sandboxSessions
Connect & automate
ComputersConnectorsInkbox: iMessage, SMS, email & callsMCP serverPixio: images, video & audioRemindersSlack & channelsMicrosoft TeamsTriggers
Feature flags
Apps

Develop

CLI
TypeScript SDKFull exampleAuthenticationSign in with KortixSessionsAppsReact hooksSDK reference
Machine as a BackendAPI reference
GitHub
SDK

React hooks

Run a Kortix session in React with the useSession hook.

@kortix/sdk/react adds React hooks on top of the SDK. This page covers useSession, the hook that runs a session end to end, and the other hooks confirmed stable for React apps.

useSession(projectId, sessionId, options?)

useSession starts the session, opens the server-selected event transport, and syncs messages, status, and pending prompts. Call it once per session view.

tsx
import { useSession } from '@kortix/sdk/react';

function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) {
  const s = useSession(projectId, sessionId);

  if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />;

  return (
    <>
      {s.messages.map(({ info, parts }) => (
        <Message key={info.id} info={info} parts={parts} />
      ))}
      <Composer busy={s.isBusy} onSend={s.send} onStop={s.cancel} />
    </>
  );
}

Readiness is server truth. The runtime is ready when POST /start returns stage: 'ready'. useSession does not run a separate client-side health check.

Returns

FieldTypeWhat it holds
phase'starting' | 'ready' | 'error'Overall state. Render a boot screen until ready.
messages{ info, parts }[]The message list. Parts stream in live.
savedTranscript'loading' | 'shown' | 'none'Whether the saved conversation can show before the computer wakes. loading: a saved copy is on its way. shown: messages are in the list. none: nothing can show until the runtime answers.
conversationEmptybooleanThe saved copy proves the conversation empty, no turn ended since, and nothing is open or queued. Render the composer, not a boot screen.
statusSessionStatusThe session status.
isBusybooleanThe agent is generating a reply.
questions, permissionsarrayPending agent questions and tool-approval requests. A permission here is one runtime tool approval, not an IAM permission.
diffs, todosarrayLive file diffs and todo items.
sendErrorKortixSendError | nullThe last send failure: billing, runtime-not-ready, or runtime-error.
rewindMessageIdstring | nullThe selected user message while a reversible rewind is staged.
rewindPendingbooleanA rewind or restore request is in progress.
rewindErrorKortixSendError | nullThe last rewind or restore failure.
models, agents, defaultAgent, commands—Selectable models, selectable agents, the default agent, and slash commands. Available before the runtime starts.
retry() => voidForce a re-check of /start.

Actions

ActionWhat it does
send(text, override?)Send a prompt. override sets { model?, agent? } for this message only.
sendParts(parts, override?)Send text and runtime URL file parts through the selected transport. Private staged handles use platform prompt routes.
rewind(messageId)Rewind this canonical session to a user message. The selected message and later path become hidden and recoverable.
restoreRewind()Restore the removed path before another prompt commits its replacement.
cancel()Stop the current run and clear pending questions and permissions. Holds the session's queued prompts first, so a queued prompt does not start a new run.
runCommand(command, args)Run a project slash command.
answerQuestion(id, answers)Answer a pending agent question.
rejectQuestion(id)Reject a pending agent question.
answerPermission(id, reply, message?)Answer a tool-approval request. reply is 'once', 'always', or 'reject'.

useSession also returns removeQuestion and removePermission. Do not use them. They clear the prompt from local state but never notify the agent, so the run stays blocked. Use answerQuestion, rejectQuestion, or answerPermission instead.

Options

OptionDefaultWhat it does
waitMs15000The long-poll budget sent to /start.
replayStartStashtrueReplay a prompt saved before the session existed, once the session is ready.
enabledtrueSet false to delay the hook, for example until a billing check passes.
chatEnginetrueSet false if your app mounts its own chat surface for this session, to avoid syncing messages twice.
initialRuntimeSessionIdnullA runtime session pin your server already holds for this session. The transcript renders from it while /start runs; the /start answer stays authoritative. initialOpenCodeSessionId is its deprecated name.
repositoryMode—Telemetry only. A session created before a repository replacement starts and runs the project's current config without it. Only its /workspace clone still comes from the old repository, so a push from it needs a rebase first.

Sending is optimistic. send shows your message right away, then stream events fill in the agent's reply.

rewind(messageId) never creates a session. It uses the canonical session from POST /start. The runtime restores file state and keeps the removed transcript path recoverable. The next accepted prompt commits the replacement path.

Other stable hooks

@kortix/sdk/react also exports React Query hooks for data that does not need a running session. Each mirrors a method on the client and needs no provider.

HookReads
useProjectModels(projectId)Selectable models for the project.
useProjectModelPickerCatalog(projectId)The raw /model-picker record per wire model (reasoning_options, temperature, limit) and managedPricingRoutes customer price estimates for each eligible managed route. The server includes its configured markup; the serving route determines the final charge.
useProjectSessions(projectId, { parent, startedBy, q, limit })One project's sessions, paged. parent: 'root' returns top-level sessions with child_count; startedBy: 'me' | 'others' | 'automated' filters by who started the run; q searches every session the viewer may open, not only loaded pages. Each filter set has its own cache entry.
useSessionChildren(projectId, parentSessionId, { q, limit, enabled })The sessions one parent spawned (parent=<id>), newest first, paged. Set enabled: false until the parent is opened.
useVisibleAgents({ projectId })The project's visible agents.
useProjectConfig(projectId)The project's runtime config: default agent, commands.
useProjectSecrets(projectId)Secrets: list, add, remove, and personal overrides.
useAccountSecretResources(accountId)Account provider-secret metadata and use grants. Values are never returned.
useSessionProviderSecretPools(projectId, sessionId)Configured pools, including empty selections; setPool updates one provider and refreshes the session cache.
useProjectTriggers(projectId)Triggers: list, create, update, remove, fire.
useChangeRequests(projectId, status?)Change requests: list, open, merge, close, request changes.
useRuntimeSupports(capability)Whether the open session's runtime serves a feature (session.rewind, session.compact, …), from the /kortix/health answers the runtime-reconnect poller reads. true until the first answer. See What the runtime supports.
resetRuntimeQueries(queryClient)Not a hook. Drops every cached runtime query (sessions, messages, agents, commands, diffs). Call it after the session's runtime is replaced by a restart or a config reload, so the next read comes from the new runtime.

Connector setup links

An agent can mint a 1-click connect link (/connect/<token>). The flow's data and runtime policy live in the SDK; a host renders its own dialog over them and injects its adapters.

ExportRole
useConnectorSetup(token, options)The whole flow: load what the link names, POST /start, open the provider's hosted page through the injected openPopup, poll POST /finalize on the 3 s / 5 s / 5-minute schedule, and report phase (loading, ready, starting, opened, connected, error), the app info, the error, and who the account was authorized as. options carries the host's backendUrl, storage adapter and openPopup; onOpened fires after each popup opening.
useConnectorLinkInfo(token, options)One shared, storage-backed cache per link token, so every card and the modal share one GET of the rate-limited public route and paint the app's logo on the first frame. The same backendUrl/storage options.
createConnectorLinkInfoCache(fetchInfo, { storage })The cache factory itself (framework-free, storage injected) for a host that wants its own instance.
resolveConnectorStart({ start, finalize })The /start outcome rule: hosted url → popup, already connected → finalize once, otherwise the error message.
nextConnectorPollDelay(attempt, elapsedMs)The bounded poll schedule (3 s first, 5 s after, stops at 5 minutes).

Start the open read early

prefetchSessionOpen(queryClient, projectId, sessionId) starts the session-open read before the session view mounts. It issues one GET .../snapshot request (session row, turn, queue, and the first 40 transcript messages) and seeds the session row cache from it. useSession then uses that request instead of starting its own.

Call it when a route names a session, or on hover, focus, or touch of a session link. It is read-only: it never calls /start and never wakes a computer. It never rejects. A repeat call for the same session within 30 seconds sends no request.

tsx
import { prefetchSessionOpen } from '@kortix/sdk/react';

<a
  href={`/projects/${projectId}/sessions/${sessionId}`}
  onFocus={() => void prefetchSessionOpen(queryClient, projectId, sessionId)}
/>;

Render from cache on a reload

Two stores keep what a user already saw, so a reload or a cold app start renders it in the first frame and refreshes it in place. Both are per user and bounded, and a host clears both on sign-out.

  • createPersistedQueryCache keeps the React Query results a user navigates by: accounts, projects, project detail, the paged session list, and session rows (isPersistableQueryKey). A restored entry keeps its original age, so it refetches on mount. The default bounds are 1,000,000 characters and 7 days.
  • createSavedCopyStore keeps the last saved copies of transcripts that the server sent. Register it with setSavedCopyStore, and useSession paints the kept copy before the first frame. The server's copy then reconciles into it by message ID, and the SDK re-reads it 3 seconds after each turn ends. The default bounds are 12 sessions, 1,500,000 characters, and 14 days. A copy over 300,000 characters keeps its newest messages that fit; older messages load from the server when the user scrolls up.
tsx
import {
  browserKeyValueStorage,
  createPersistedQueryCache,
  createSavedCopyStore,
  isPersistableQueryKey,
  setSavedCopyStore,
} from '@kortix/sdk';

const storage = browserKeyValueStorage(); // null where there is no localStorage
if (storage) {
  const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
  cache.restore(queryClient); // synchronous with localStorage: no pending frame
  const stop = cache.persist(queryClient);
  setSavedCopyStore(createSavedCopyStore({ storage, userId }));
}

On React Native, pass AsyncStorage as storage. restore then returns a promise.

The saved-copy store keeps only server captures: the transcript mirror that the API writes when a turn ends. The live transcript is never written to the device, so a stopped turn cannot come back as a running one.

Sign-out and account switching

The SDK keeps per-user session state in memory: transcripts, pending questions and permissions, turn receipts, and model picks. Call resetIdentityState() on every identity change: sign-out, and a different user signing in. This includes a sign-in in another tab that changes the user without a page load.

tsx
import { resetIdentityState } from '@kortix/sdk/react';

supabase.auth.onAuthStateChange((event, session) => {
  if (event === 'SIGNED_OUT' || session?.user.id !== previousUserId) {
    queryClient.clear();
    resetIdentityState();
  }
});

resetIdentityState() also removes the model store's localStorage entry. It does not clear the host's own caches, such as React Query, or the token source passed to createKortix. Reset those in the same handler. It never throws.

With the stores from Render from cache on a reload, clear them in the same handler: await cache.clear(), then await savedCopies.clear() and setSavedCopyStore(null). Neither store ever restores data for a different user, but a signed-out device should not keep it.

Next

  • Sessions — the session handle useSession wraps, and the KortixSendError kinds.
  • Reference — the full REST surface these hooks read from.

Saved history during startup

useSession(projectId, sessionId) shows saved chat history while its computer starts. The SDK reads the database independently of runtime readiness. Live messages then reconcile by their original IDs. Hosts do not need a separate history request or message store.

Saved history renders exactly like the live transcript: text, reasoning, and every tool call with its input, output and error. A sub-agent's full view reads the sub-agent's own saved transcript: pass the parent's scope and savedChild: true to useSessionSync(subAgentSessionId, { kortixSessionScope, savedChild: true }). The initial window contains the newest 40 messages, at most 1,000,000 characters. Scrolling up loads older windows from saved history, also while the computer is off. Attachments that require the computer become available when the runtime is ready.

savedTranscript tells a host what to render while the computer starts. It is loading while a saved copy may still arrive, shown once messages are in messages, and none when nothing can show before the runtime answers. An unknown is loading, never none.

conversationEmpty is true when there is nothing to wait for. The saved copy proves the conversation empty: a complete read of the session's runtime found no messages. No turn ended since, and nothing is open or queued. Missing records never make it true, so an older session with history is never shown as empty. A session gets that proof at its first start with saved history on.

tsx
if (s.messages.length === 0) {
  // Nothing saved and nothing to wait for: the user can type now.
  if (s.conversationEmpty) return <Composer />;
  // One round trip for a saved copy; the computer can take minutes.
  return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
}

Tool calls saved before 2026-09-28 lost their input and output. The server serves each one with the input its kept title or metadata proves: a command's command and output, a file call's path, a search's pattern, a sub-agent's description, a fetch's URL, a todo list, a skill's name. The session's next start saves the whole history again, 1:1.

Pending prompts

useSessionPrompts(projectId, sessionId) submits to the durable session inbox. The server runs pending prompts automatically, each as its own turn.

Pass placement: 'transcript' to display a pending message in the conversation. It runs before every composer entry and ends the active response after the current tool call completes. Pass placement: 'composer' to keep it in the editable list above the input. It waits for the active response to finish. Each placement keeps submission order. Pass delivery: 'steer' | 'queue' | 'interrupt' to say how the prompt reaches a running turn. steer hands it to the turn at its next step; the turn does not stop. queue waits for the turn to end. interrupt ends the turn after the running tool. With only delivery, the SDK sends the placement it implies: interrupt is transcript, the others are composer. A steer row that cannot steer reads delivery: 'queue' with steer_fallback set to unsupported, not_prompter or turn_ended. interrupt(promptId) ("Stop and send") turns a waiting row into a transcript entry; a row already on the wire answers 409.

The web composer sends Enter as steer while a turn runs, and as a transcript send while the session is idle. Command+Enter (Control+Enter on Windows/Linux) is composer placement. Shift+Enter inserts a newline.

List rows include full_text for complete previews after reload. text remains the bounded preview for older clients. Attachment bytes are excluded from list responses. Delete returns the complete prompt for lossless undo.

Pending delivery and active responses

useSessionWorking() keeps state: 'working' while a prompt waits in the durable inbox. This keeps Stop available. pendingDelivery: true marks a prompt still on its way to the runtime; the web app shows one working indicator whenever state is working, whatever that flag says. Active runtime turns and fresh runtime activity clear it. A timed-out cancel does not confirm that execution stopped.

A worker claim only checks admission and keeps the prompt waiting. Delivery starts after admission succeeds. A confirmed active turn clears the pending presentation even if the previous inbox snapshot still lists that prompt. Runtime activity preserves the active turn's message ID during this handoff.

The web composer names Command/Ctrl+Enter Queue List and a row's "Stop and send" Quick Queue. Quick Queue entries run first. A waiting Quick Queue entry is a tinted bubble after delivered turns, with no status text. Queue List entries remain editable until delivery starts. The server recovers missed completion events from exact runtime evidence, including after reload, without treating an unreachable runtime as a completed turn.

Each distinct Enter displays its pending prompt immediately, even while an earlier acceptance request is unresolved. Only a delivery failure adds text: its cause, Retry, and Remove. The server inbox continues to own execution order.

The hook reads queue changes from the session's control stream (GET .../sessions/:id/events?channels=control), one connection per session however many components mount it. Every write of an inbox row arrives as a kortix.control.queue frame. The GET .../prompts poll runs only while that stream is not connected.

Why a turn ended

The transcript of an interrupted turn only carries MessageAbortedError. The control plane keeps what it knows about how each turn ended: the cause the sandbox named, for example SandboxMemoryGuard, and which turns died with no cause named. A stop somebody asked for — the Stop button, abort(), a prompt sent into a busy session — is recorded as a request and is never reported as a failure.

useSessionTurnOutcome(projectId, sessionId) returns last_ended, recent_failures and the read time atMs from the cache useSessionWorking() keeps fresh. It makes no request. Pass the result to turnEndNotice, with what the transcript says about the turn:

tsx
const outcome = useSessionTurnOutcome(projectId, sessionId);
const notice = turnEndNotice(outcome, turn.userMessage.info.id, {
  hasError: Boolean(turnError),
  isAbort: turnErrorIsAbort,
});
// notice: { kind: 'sandbox-memory', usedPct: 97, detail: 'sandbox memory at 97% …' }
//       | { kind: 'cause', name: 'SomeGuard', message: '…' }
//       | { kind: 'unexplained' }
//       | null

The host maps each kind to its own copy and never parses the sandbox's message. null means "show what the transcript shows": a real error stays, an abort renders nothing. An unexplained failure is reported only once the reading is a few seconds past the turn's end, because the cause often arrives one frame after the abort. turnEndCause(outcome, messageId) returns the raw recorded cause, or null.

PreviousAppsNextSDK reference

On this page

useSession(projectId, sessionId, options?)ReturnsActionsOptionsOther stable hooksConnector setup linksStart the open read earlyRender from cache on a reloadSign-out and account switchingNextSaved history during startupPending promptsPending delivery and active responsesWhy a turn ended