Klein Kit

React hooks

Stream sessions, permissions, plans, and workflows into your UI

@klein-kit/react turns a live agent session into React state — messages, streaming tool output, pending permissions, plans, todos, usage — via useSyncExternalStore, with stable sub-array identities so children memoize cleanly.

pnpm add @klein-kit/react @klein-kit/core react

Peer deps: react >= 18, @klein-kit/core, and optionally @klein-kit/orchestrate (only needed for useWorkflow).

useAgentSession

import { useAgentSession, useProviders } from "@klein-kit/react";

function Chat() {
  const { available } = useProviders();
  const agent = useAgentSession({ provider: "codex", permissionMode: "ask" });

  return (
    <>
      {agent.messages.map((m) => (
        <div key={m.id}>
          <b>{m.role}</b>: {m.text}
          {m.tools.map((t) => (
            <pre key={t.id}>{t.name} [{t.status}] {t.output}</pre>
          ))}
        </div>
      ))}

      {agent.permissions.map((p) => (
        <div key={p.id}>
          {p.title}
          <button onClick={() => agent.respond(p.id, { behavior: "allow" })}>Allow</button>
          <button onClick={() => agent.respond(p.id, { behavior: "deny" })}>Deny</button>
        </div>
      ))}

      <PromptBox
        busy={agent.status === "streaming"}
        onSend={(text) => agent.send(text)}
      />
    </>
  );
}

Options are SessionOptions plus:

OptionEffect
providerRequired. Changing it starts a new session.
resumeResume a persisted nativeSessionId instead of starting fresh. Changing it also restarts.
enabledDefault true. Set false to defer session creation (e.g. until the user picks a provider).
onPermissionRequestDecide in code; omit to surface requests in permissions for your UI to respond() to.

Other option changes apply to later turns only — re-renders never restart the session. The session is created on mount and closed on unmount.

The result extends SessionSnapshot:

interface SessionSnapshot {
  status: "idle" | "streaming" | "closed" | "error";
  messages: Message[];        // { id, role, text, streaming, reasoning?, tools: ToolRun[] }
  plan: PlanStep[];
  todos: TodoItem[];
  permissions: PermissionRequest[];
  usage: Usage;
  limits: RateLimit[];
  filesChanged: FileChange[];
  diff: string | undefined;   // cumulative turn diff, when the provider reports one
  queued: { turnId: string; text: string }[];
  error: string | undefined;
}

plus imperative handles: session, ready, send(input, options?) → turnId, respond(requestId, response), interrupt(), cancelQueued(turnId), and clear() (resets the transcript without touching the session).

ToolRun extends ToolInvocation with status: "running" | "success" | "error" | "canceled" and the streamed output so far — render live tool output directly from the message tree.

useProviders

const { providers, available, loading, error, refresh } = useProviders();
// available = installed && authenticated !== false — the sensible menu contents

Wraps detectProviders (side-effect free, safe on mount). Pass useProviders(["codex", "claude"]) to probe a subset.

useWorkflow

Lives on a subpath so @klein-kit/orchestrate stays an optional dependency:

import { useWorkflow } from "@klein-kit/react/workflow";

const wf = useWorkflow();

wf.run(definition);       // ignored while a run is in flight
wf.abort();               // interrupts in-flight turns

wf.status;                // "idle" | "running" | "completed" | "failed"
wf.steps;                 // StepView[]: { id, status, provider?, attempt, output, verdict? }
wf.taskState;             // updated on every handoff
wf.results;               // Record<string, StepResult> when done

Each StepView.output accumulates the assistant text streamed for that step, and verdict reflects reviewer approve/revise decisions — enough to render a live pipeline view with no extra wiring.

SessionStore

The store behind useAgentSession is exported for non-hook contexts (state libraries, tests, SSR shells): getSnapshot(), subscribe(listener), attach(session), detach(), reset().

On this page