# React hooks @kortix/sdk/react — one hook to run a session (useSession), plus reactive hooks for models, config, PTY, and the session list. Canonical page: https://kortix.com/docs/sdk/react `@kortix/sdk/react` is the **reactive** half of the SDK. Where `createKortix(...)` is imperative — you call a method, you get a promise — these hooks subscribe to **live** data and re-render as it changes. They're built on [React Query](https://tanstack.com/query) and [zustand](https://zustand-demo.pmnd.rs/), and kept current by OpenCode's SSE event stream. You almost never touch the runtime plumbing directly. **One hook runs a session:** ```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 ; return ( <> {s.messages.map(({ info, parts }) => ( ))} s.send(text)} onStop={s.cancel} /> ); } ``` That's the whole contract. No event provider to mount, no health poller, no "server store" to switch, no sandbox id, no canonical-session resolution — `useSession` owns all of it internally. ## `useSession(projectId, sessionId, options?)` The single hook for an open session. Internally it drives `POST /start` (the server long-polls), resolves the session's runtime, opens the SSE stream, resolves the canonical OpenCode id, and syncs messages — exposing one `phase` plus the thread, the actions, the interactive prompts, and the pre-runtime capabilities. **Readiness is server-truth:** the runtime is ready when `/start` returns `stage: 'ready'` (the backend only returns that once the daemon answered). There is **no client-side health poll** — so a fresh session's first turn streams immediately, and the old "stuck until you hard-refresh" failure mode cannot occur. Call it **once per session view** (like a provider) — it owns the SSE subscription and the `/start` poll for that `(projectId, sessionId)`. ### Returns | field | type | what | | ------------------------------------ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `phase` | `'starting' \| 'ready' \| 'error'` | top-level gate — render the boot screen until `ready` | | `stage` | `SessionStartStage \| null` | raw `/start` stage (`provisioning` / `starting` / `ready` / …) for boot UI | | `runtimePhase` | `'connecting' \| 'booting' \| 'ready' \| 'unreachable'` | granular live connection phase (for a mid-session reconnect pill) | | `messages` | `{ info, parts }[]` | the thread; parts stream in live | | `status` · `isBusy` | `SessionStatus` · `boolean` | session status · agent is generating | | `isLoading` · `isError` | `boolean` | first hydrate in flight · terminal `/start` state | | `questions` · `permissions` | `[]` | pending agent questions / approvals (from the live prompt store) | | `hasPending` | `boolean` | there are open questions/permissions | | `diffs` · `todos` | `[]` | live file diffs · todo items | | `pending` | `string \| null` | the optimistic in-flight user message text | | `isSending` · `sendError` | `boolean` · `KortixSendError \| null` | current `send` call in flight · its last failure (billing / runtime-not-ready / runtime-error), reset on every new `send` | | `opencodeSessionId` | `string \| null` | the resolved canonical OpenCode root id | | `models` · `agents` | `[]` | selectable models · agents (server-side, available pre-runtime) | | `defaultAgent` · `commands` | `string \| null` · `[]` | the project default agent · slash commands | | `picks` | `SessionPicks` | per-session model/agent selection (persisted) | | `sandbox` · `switched` · `retriable` | row · `boolean` · `boolean` | the `/start` sandbox row · runtime switched-in · can re-poll | | `startError` · `reason` | terminal `/start` failure · `string \| null` | render instead of spinning forever · latest `/start` reason (e.g. `'runtime_waking'`) for boot UI | ### Actions | action | what | | --------------------------------------- | ----------------------------------------------------------------------------------- | | `send(text, override?)` | send a prompt (optimistic). `override` = `{ model?, agent? }` for this message | | `cancel()` | abort the run **and** clear any pending prompt + open questions/permissions | | `runCommand(command, args)` | run a project slash-command (`/command`) | | `answerQuestion(id, answers)` | reply to a pending agent question through the server | | `rejectQuestion(id)` | reject a pending agent question through the server | | `answerPermission(id, reply, message?)` | reply to a pending permission request — `reply` is `'once' \| 'always' \| 'reject'` | | `retry()` | force a re-poll of `/start` (e.g. a boot-screen Retry button) | > **Warn** > `removeQuestion(id)` / `removePermission(id)` are also on the returned object but **deprecated**: > they drop the prompt from local state *without* replying to the server, so the agent run stays > blocked waiting on it. Use `answerQuestion` / `rejectQuestion` / `answerPermission` above instead. ### Options | option | default | what | | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `waitMs` | `15000` | long-poll budget requested on `/start` (the server clamps it) | | `replayStartStash` | `true` | replay a stashed first message (see [start-stash](#start-stash)) once ready + empty | | `enabled` | `true` | gate the whole hook — set false until a precondition resolves (e.g. a billing check) | | `chatEngine` | `true` | mount the chat-consumption engine (message sync + the question self-heal poll) on this call. Set `false` when the host mounts its _own_ chat surface for the same session (e.g. `apps/web`'s `SessionChat`) — avoids double-mounting the same pollers; `messages`/`diffs`/`todos` stay empty and `replayStartStash` is force-disabled in that mode | > **Note** > Sending is optimistic and fire-and-forget: `send` shows your message instantly, then SSE events > stream the agent's reply into the thread. `useSession` clears the optimistic bubble when your > message lands (or after a 30s backstop). ## `KortixProjectProvider` `useSession` itself needs no provider — but a few route-scoped hooks (like `useOpenCodeProviders()` under [More hooks](#more-hooks)) resolve "the project the user is looking at" from React context instead of a parameter, since the SDK is router-agnostic and can't call `useParams()` itself. A Next (or other router-based) host derives the project id once and mounts the provider near its root: ```tsx import { KortixProjectProvider } from '@kortix/sdk/react'; function ProjectLayout({ projectId, children }: { projectId: string; children: React.ReactNode }) { return {children}; } ``` `useKortixRouteProjectId()` is the read side — it's what those hooks call internally, and it's exported for a host that wants the same value. Outside a project scope it returns `null`. ## Capability hooks (available before the runtime) Model, agent, and command catalogs come from the **server**, so you can build a picker on a "new session" screen _before_ any sandbox exists. These are plain React Query reads — no runtime required. ```tsx import { useProjectModels, useVisibleAgents, useProjectConfig } from '@kortix/sdk/react'; const models = useProjectModels(projectId); // selectable model rows const agents = useVisibleAgents({ projectId }); // visible agents const config = useProjectConfig(projectId); // { open_code_default_agent, commands, … } ``` `useSession` already exposes these as `s.models` / `s.agents` / `s.defaultAgent` / `s.commands` for a session view; use the standalone hooks on screens that don't hold a session yet. ## Primitives Small building blocks the white-label reference uses — all owned by the SDK so every host shares one implementation. | primitive | what | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `useSessionPicks(sessionId)` | `{ model, agent, setModel, setAgent }` — per-session selection, persisted locally | | `useRuntimePhase()` | the granular `'connecting' \| 'booting' \| 'ready' \| 'unreachable'` phase (also `s.runtimePhase`) | | `generateSessionId()` | an RFC-4122 v4 id, with a fallback for non-secure contexts (http on a LAN) — imported from the **root** `@kortix/sdk` entry, not `/react` | **Start-stash** — the new-session → workbench hand-off. Your "what would you like to build?" screen collects a prompt + model + agent before the runtime exists; stash them under the new id, and `useSession` replays them as the first message once ready: ```tsx import { writeStartStash } from '@kortix/sdk/react'; import { generateSessionId } from '@kortix/sdk'; // root entry — not re-exported from /react // new-session screen: const sessionId = generateSessionId(); await kortix.project(projectId).sessions.create({ session_id: sessionId, name }); writeStartStash(sessionId, { prompt, model, agent }); router.push(`/projects/${projectId}/sessions/${sessionId}`); // the session page just calls useSession(...) — the stash replays automatically. ``` Also `readStartStash(sessionId)` / `clearStartStash(sessionId)` / `startStashKey`. ## Session list & CRUD The session list and per-session lifecycle, as React Query queries and mutations. ```tsx import { useOpenCodeSessions, useCreateOpenCodeSession, useUpdateOpenCodeSession, useDeleteOpenCodeSession, } from '@kortix/sdk/react'; function Sidebar() { const { data: sessions = [] } = useOpenCodeSessions(); const create = useCreateOpenCodeSession(); const rename = useUpdateOpenCodeSession(); const remove = useDeleteOpenCodeSession(); // … } ``` | hook | purpose | | ----------------------------------------------------------- | --------------------------------------------------- | | `useOpenCodeSessions()` · `useOpenCodeSession(id)` | the live session list · one record | | `useCreateOpenCodeSession()` | create `{ directory?, title? }` | | `useUpdateOpenCodeSession()` | rename / archive `{ sessionId, title?, archived? }` | | `useDeleteOpenCodeSession()` | delete | | `useSummarizeOpenCodeSession()` · `useInitSession()` | compact the thread · run `/init` | | `useOpenCodeSessionDiff(id)` · `useOpenCodeSessionTodo(id)` | working diff · todo list | ## Project data hooks Reactive counterparts of the imperative [`p.secrets`](/docs/sdk/the-client#p-secrets) / [`p.triggers`](/docs/sdk/the-client#p-triggers) / [`p.changeRequests`](/docs/sdk/the-client#p-changerequests) surfaces on [The client](/docs/sdk/the-client) — each a thin React Query binding with its own query-key factory, and mutations that invalidate the list so a write reflects without a manual refetch. ```tsx import { useProjectSecrets, useProjectTriggers, useChangeRequests } from '@kortix/sdk/react'; const secrets = useProjectSecrets(projectId); // { data, upsert, remove, setPersonal, removePersonal } const triggers = useProjectTriggers(projectId); // { data, create, update, remove, fire } const crs = useChangeRequests(projectId, 'open'); // { data, open, merge, close, requestChanges } ``` | hook | mirrors | query key | | --------------------------------------- | ------------------------------------------------------------------------ | ------------------------------- | | `useProjectSecrets(projectId)` | `p.secrets` — list + upsert/remove, personal-override set/remove | `projectSecretsKey(projectId)` | | `useProjectTriggers(projectId)` | `p.triggers` — list + create/update/remove/fire | `projectTriggersKey(projectId)` | | `useChangeRequests(projectId, status?)` | `p.changeRequests` — list (filterable) + open/merge/close/requestChanges | `changeRequestsKey(projectId)` | ## Runtime config Read and write OpenCode's runtime config (`opencode.jsonc` as the server sees it); updates are optimistic and roll back on error. ```tsx import { useOpenCodeConfig, useUpdateOpenCodeConfig } from '@kortix/sdk/react'; const { data: config } = useOpenCodeConfig(); const update = useUpdateOpenCodeConfig(); update.mutate({ permission: { edit: 'allow' } }); ``` ## MCP servers > **Warn** > **Being retired.** Tools/connectors are moving to the **Executor connectors** model — manage them > via [`kortix.project(id).connectors`](/docs/sdk/the-client#p-connectors), not these runtime MCP > hooks. Documented for the current runtime, but prefer connectors for new code. `useOpenCodeMcpStatus()`, `useAddMcpServer()`, `useConnectMcpServer()`, `useDisconnectMcpServer()`, and the OAuth trio `useMcpAuthStart()` / `useMcpAuthCallback()` / `useMcpAuthRemove()`. ## PTY terminal A live terminal into the session's sandbox — list/create/remove/resize PTYs, then open a WebSocket for the byte stream. Backed by Kortix's own PTY implementation in the sandbox daemon (`/kortix/pty`), independent of whatever agent runtime is running — the hook names/shapes below are unchanged from before that move. ```tsx import { useOpenCodePtyList, useCreatePty, useRemovePty, getPtyWebSocketUrl, } from '@kortix/sdk/react'; const { data: ptys = [] } = useOpenCodePtyList(); const create = useCreatePty(); const url = await getPtyWebSocketUrl(id); // wss://… with the auth token appended ``` Use `useUpdatePty()` to push a title or `{ rows, cols }` resize. ## Model store `useModelStore(allModels, opts?)` is a zustand-backed store of the user's model preferences — visibility, recents, per-agent and per-session selections, the global default, variants. Persisted client-side and pure UI state (it does **not** fetch — pass the flattened compact catalog from `kortix.project(id).modelPicker()` or `useProjectModels()`). ```tsx import { useModelStore, type ModelKey } from '@kortix/sdk/react'; const store = useModelStore(models, { connectedProviderIds }); store.setSessionModel(sessionId, model); ``` Helpers include `isVisible` / `setVisibility`, `recent` / `pushRecent`, `getVariant` / `setVariant`, `getSessionModel` / `setSessionModel`, `globalDefault` / `setGlobalDefault`, and `userPrefs`; `seedGlobalDefaultFromServer(model)` — a plain function, not a hook — seeds the global default from the server the first time it resolves. ## Lower-level hooks (what `useSession` composes) > **Warn** > **Internal plumbing.** These are the pieces `useSession` wraps — the SSE provider, message sync, > the prompt mutations, and the OpenCode-id resolver. A chat-focused host uses `useSession` and > never touches them. They stay exported for hosts that build **beyond chat** (a full IDE — file > tree, terminal, git) and for `apps/web`'s own `session-chat`; if you're just rendering a > conversation, reach for `useSession`. | hook | composed role | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `useSessionSync(sessionId)` | fetch + live-merge the thread off the sync store | | `useSendOpenCodeMessage()` · `useAbortOpenCodeSession()` | prompt · abort — both take no arguments; the runtime is resolved through the single global client, not a per-session one | | `useExecuteOpenCodeCommand()` | run a slash-command | | `OpenCodeEventStreamProvider` / `useOpenCodeEventStream()` | open the SSE stream → the stores | | `useCanonicalOpenCodeSession({ projectId, sessionId, pinFromStart })` | resolve the OpenCode root id | | `useOpenCodePendingStore()` | the live questions/permissions store | | `useRuntimeReconnect()` | the independent liveness probe that detects a mid-session runtime death the SSE heartbeat can't see — polls `getSessionHealth`/`isRuntimeReady` and writes into the connection store | | `useQuestionSelfHeal()` / `hasRunningQuestionTool` | polls for a missed `question.asked` SSE event and repairs pending-question state | | `usePermissionSelfHeal()` / `findPermissionBlockedCandidate` / `hasActiveNonQuestionTool` | the same self-heal watchdog for missed permission requests | | `useGatewayCatalogSync(projectId)` | keeps the gateway model catalog cache warm | | `useGatewayRoutingPolicy(projectId)` | reads routing policy and exposes set, reset, and preview mutations | ## More hooks | hook | what | | ------------------------------------------------------------ | -------------------------------------------------- | | `useProjectModels` · `useVisibleAgents` · `useProjectConfig` | server-side models · agents · config (pre-runtime) | | `useOpenCodeProviders()` | configured providers + their models | | `useOpenCodeCommands()` · `useExecuteOpenCodeCommand()` | slash commands · run one | | `useOpenCodeToolIds()` · `useOpenCodeSkills()` | available tool ids · skills | | `useShareSession()` · `useUnshareSession()` | public share links | | `useBackgroundSessionPrefetch(sessions)` | warm message caches on hover | The `opencodeKeys` query-key factory is exported too, for targeted `invalidateQueries` / `setQueryData` against the same cache. ## Kortix Master (tasks, tickets, credentials, projects) `@kortix/sdk/react` also re-exports the full **Kortix Master** React Query layer — the hooks behind a project's task board, ticket/kanban system, credentials vault, and project/team management. This is the same hook set `apps/web`'s Kortix UI runs on end to end; it's exported from the SDK so any host can build the same surface. These hooks talk to the `/kortix/*` daemon routes and are otherwise plain React Query — no relation to `useSession`'s runtime plumbing. > **Note** > Most of these hooks take a **`KortixMasterIdentity`** as their first argument > instead of reading an auth hook internally: > > ```ts > interface KortixMasterIdentity { > userId: string | null; > handle: string; // stamped as actor_id / created_by_id on mutations > isLoading: boolean; > } > ``` > > This is the one seam between the SDK and a host's auth stack — derive it from > Supabase, a mobile auth SDK, a service-account token, whatever the host uses, > and pass it in. Hooks that don't need authorship (plain reads/writes scoped > only by project or id) skip the argument. > ### Credentials A project's secrets vault (`/kortix/projects/:id/credentials`) — values are never returned by the list call; `useRevealCredential` is a separate, audit-logged read. ```tsx import { useCredentials, useUpsertCredential, useRevealCredential } from '@kortix/sdk/react'; const { data: credentials = [] } = useCredentials(projectId); const upsert = useUpsertCredential(); upsert.mutate({ projectId, name: 'STRIPE_KEY', value: 'sk_...', description: 'Stripe secret key' }); ``` | hook | purpose | | -------------------------------------------------------------------- | ----------------------------------------------------------- | | `useCredentials(projectId)` · `useCredentialEvents(projectId, name)` | list a project's credentials · one credential's audit trail | | `useUpsertCredential()` | create or replace a credential's value | | `useRevealCredential()` | decrypt + return one value (audit-logged as a read) | | `useDeleteCredential()` | remove a credential | ### Kortix projects The project registry itself (`/kortix/projects`) — distinct from an SDK `Project` (a sandbox/session container); a Kortix project is the task/ticket/credentials workspace layered on top. ```tsx import { useKortixProjects, useKortixProject, usePatchProject } from '@kortix/sdk/react'; const { data: projects = [] } = useKortixProjects(identity); const { data: project } = useKortixProject(identity, projectId); ``` | hook | purpose | | ---------------------------------------------------------------- | ---------------------------------------------------------------- | | `useKortixProjects(identity)` · `useKortixProject(identity, id)` | list · one project | | `useKortixProjectForSession(identity, sessionId)` | resolve the Kortix project a session belongs to (`null` if none) | | `useKortixProjectSessions(identity, projectId)` | OpenCode sessions linked to a project | | `usePatchProject()` | update name / description / `user_handle` | | `useDeleteProject()` | delete a project | ### Tasks The async task queue (`/kortix/tasks`) — a task is a unit of work an agent picks up, with a `verification_condition` an approval step checks against. ```tsx import { useKortixTasks, useCreateKortixTask, useStartKortixTask, useApproveKortixTask, } from '@kortix/sdk/react'; const { data: tasks = [] } = useKortixTasks(projectId, 'in_progress'); const create = useCreateKortixTask(); create.mutate({ project_id: projectId, title: 'Fix the flaky test', verification_condition: 'CI green', }); ``` | hook | purpose | | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `useKortixTasks(projectId?, status?)` · `useKortixTask(id)` | list (optionally filtered) · one task, both poll every 3s | | `useKortixTaskEvents(id)` · `useKortixTaskStatus(id)` | a task's event log · live execution status | | `useCreateKortixTask()` · `useUpdateKortixTask()` | create · patch | | `useStartKortixTask()` | hand the task to an agent (`status → in_progress`) | | `useApproveKortixTask()` | approve a task sitting in `awaiting_review` | | `useDeleteKortixTask()` | delete | | `normalizeTask(raw)` | pure helper — coerces an unvalidated wire row to `KortixTask`, defaulting an unrecognized `status` to `'todo'` | `KortixTaskStatus` is one of `todo` · `in_progress` · `input_needed` · `awaiting_review` · `completed` · `cancelled`. ### Tickets & kanban The full ticket system (`/kortix/tickets`) plus a project's board configuration — columns, custom fields, templates, and the team of project agents tickets get assigned to. ```tsx import { useTickets, useCreateTicket, useAssignTicket, useCommentTicket } from '@kortix/sdk/react'; const { data: tickets = [] } = useTickets(projectId); const createTicket = useCreateTicket(identity); createTicket.mutate({ project_id: projectId, title: 'Ship the react.mdx page' }); ``` | hook | purpose | | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `useTickets(projectId?)` · `useTicket(id)` · `useTicketEvents(id)` | list (polls 3s) · one ticket · its comment/audit trail | | `useCreateTicket(identity)` · `useUpdateTicket(identity)` | create · patch (title, body, custom fields, template, milestone) | | `useUpdateTicketStatus(identity)` | move a ticket to another column | | `useAssignTicket(identity)` · `useUnassignTicket(identity)` | assign/unassign a user or agent | | `useCommentTicket(identity)` | post a comment (drives `@mention` notifications) | | `useDeleteTicket()` | delete | | `useColumns(projectId)` · `useReplaceColumns()` | board columns · replace the whole set (label, terminal/off-flow flags, default assignee) | | `useFields(projectId)` · `useReplaceFields()` | custom fields · replace the set (`text` / `number` / `date` / `select`) | | `useTemplates(projectId)` · `useReplaceTemplates()` | ticket templates · replace the set | | `useProjectAgents(projectId)` | the project's team of agents tickets can be assigned to | | `useCreateProjectAgent()` · `useUpdateProjectAgent()` · `useDeleteProjectAgent()` | manage a project agent (persona, execution mode, tool groups, model) | | `useAgentPersona(projectId, slug)` | one agent's full persona body | | `useEnsurePmSession()` | create-if-missing the project's Project-Manager chat session (idempotent) | **Activity & notifications** — derived client-side from the ticket event stream, no separate notifications endpoint: ```tsx import { useProjectActivity, computeNotifications, readLastSeen, writeLastSeen, } from '@kortix/sdk/react'; const { data: events = [] } = useProjectActivity(projectId); // polls 10s const notifications = computeNotifications( events, identity.handle, readLastSeen(projectId, identity.handle), ); ``` | hook / helper | purpose | | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | `useProjectActivity(projectId)` | the project's raw ticket-event stream (200 most recent) | | `computeUnread(events, handle, sinceIso)` | pure — unread count assigned-to-me / @mentioning me, total + per-ticket | | `computeNotifications(events, handle, sinceIso)` | pure — the same rule set as a renderable `ProjectNotification[]` | | `readLastSeen(projectId, handle)` · `writeLastSeen(projectId, handle, iso)` | localStorage last-seen cursor (write emits `kortix:last-seen-changed` for same-tab listeners) | ### Milestones Grouping/tracking layer above tickets (`/kortix/projects/:id/milestones`) — a milestone has an acceptance note and closes with a summary. | hook | purpose | | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | | `useMilestones(projectId, statusFilter?)` | list, filtered `'open' \| 'closed' \| 'all'` (polls 5s) | | `useMilestone(projectId, ref)` · `useMilestoneEvents(projectId, ref)` | one milestone (with progress) · its event log | | `useCreateMilestone()` · `useUpdateMilestone()` | create · patch | | `useCloseMilestone()` · `useReopenMilestone()` | close (with `summary_md`, optionally `cancelled`) · reopen | | `useDeleteMilestone()` | delete | | `useSetTicketMilestone()` | link/unlink a ticket's milestone (goes through `PATCH /kortix/tickets/:id`) | ### Sandbox services Long-running background processes inside the sandbox (`/kortix/services`) — dev servers, databases, workers — registered and supervised by the daemon. | hook | purpose | | -------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `useSandboxServices(identity, opts?)` | list registered services (polls 5s); `includeAll` also returns hidden/system ones | | `useSandboxServiceTemplates(identity)` | available service templates | | `useSandboxServiceLogs(identity, serviceId)` | a service's log tail (polls 3s while a `serviceId` is set) | | `useSandboxServiceAction()` | `{ serviceId, action }` — start/stop/restart a service | | `useSandboxServiceReconcile()` | re-sync the daemon's service state against what's actually running, optionally `reload` | | `useRegisterSandboxService()` | register a new service | | `useSandboxRuntimeReload()` | `{ mode }` — reload the OpenCode runtime itself (`systemReload`, shared with `../opencode/client`) | ## Next - [The client](/docs/sdk/the-client) — the imperative `createKortix` facade. - [Sessions](/docs/sdk/sessions) — the imperative session handle + the runtime. - [Modules](/docs/sdk/modules) — lower-level subpath imports.