Page actions

React interrupts preview

Pause a proposed action long enough for a person to decide. An approval card renders that decision; the application must still match it to the pending backend interrupt before resuming the run.

The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.

Keep presentation separate from resume

ApprovalCard receives a title, content, and explicit callbacks. It does not discover an interrupt or choose a resume payload. Pass callbacks from the application owner after it has matched the current pause. That lets the same visual component serve a trip booking, a refund, or another application-specific decision.

State diagramAgent status and recovery
idleNo active stream
runningWaiting or streaming
errorInspect recovery
idle → running
An accepted submit starts execution.
running → idle
The stream completes or is cancelled. Check for a pending interrupt.
running → error
A request or execution fails. Read the error details.
error → recovery
Retry only when allowed. Check an uncertain outcome before sending again.
Status describes execution. Inspect interrupts and error recovery before deciding what the user can do next.
Read diagram description

The public status values are idle, running and error. An accepted submit moves idle to running, including the wait before the first token. Completion or cancellation settles running back to idle. A failure moves to error. Only offer retry when the reported error allows it; an uncertain server outcome needs reconciliation. Interrupts and pending approvals are separate state, so idle alone does not mean a paused workflow has finished.

import { ApprovalCard } from '@threadplane/react/chat';
 
export function BookingDecision({ onConfirm, onCancel }: {
  onConfirm: () => void;
  onCancel: () => void;
}) {
  return <ApprovalCard title="Confirm the proposed booking" actions={[
    { id: 'confirm', label: 'Confirm', onSelect: onConfirm },
    { id: 'cancel', label: 'Cancel', onSelect: onCancel },
  ]}>Review the proposed details before continuing.</ApprovalCard>;
}

The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.

Review a refund

Choose Run, then describe a refund or use a suggested request. The draft arrives incrementally before the graph pauses. Review the amount, customer, and reason in the inline approval card. Approve refund resumes with approval, Decline refund resumes with rejection, and Edit amount lets you approve an explicit nonnegative amount. Blank or invalid edits cannot be submitted.

The paused conversation accepts decisions rather than another message. Text stays blocked for every pause. A malformed, unknown, or multiple-interrupt batch requires New conversation; the example never guesses which interrupt to resume. While a decision is pending, further decisions are blocked.

Stop cancels active work. After cancellation, a request failure, or an uncertain result, choose New conversation to start fresh. The example never silently replays an uncertain conversation creation or decision request.

Ownership and rendering

A stable application owner creates the session only after the conversation is confirmed. React observes its snapshots through useAgent and renders native ApprovalCard, MessageList, and ChatInput components. Each decision is bound to the exact current root interrupt batch. A replaced batch invalidates old callbacks, even when its IDs are reused. Leaving the example releases the session and fences late results.

app.tsx
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useRef, useState } from 'react';
import { useAgent } from '@threadplane/react';
import { ApprovalCard, ChatInput, MessageList } from '@threadplane/react/chat';
import type { RefundDecision, RefundResponse } from './application';
import {
  createConnectedApplication,
  type interruptsConnection,
} from './connection';
 
const suggestions = [
  {
    label: 'Refund a duplicate charge',
    value:
      'Refund $47.50 to customer cus_a8x2k — they were charged twice for the same order.',
  },
  {
    label: 'Refund a chargeback',
    value:
      'Refund $129.00 to customer cus_z19fp who opened a chargeback for unrecognized activity.',
  },
] as const;
 
function RefundApproval({
  decision,
  disabled,
  onDecide,
}: {
  readonly decision: RefundDecision;
  readonly disabled: boolean;
  readonly onDecide: (
    decision: RefundDecision,
    response: RefundResponse
  ) => void;
}) {
  const [edit, setEdit] = useState<{
    decision: RefundDecision;
    amount: string;
  } | null>(null);
  const editing = edit?.decision === decision;
  const amount = editing ? edit.amount : '';
  const valid =
    amount.trim().length > 0 &&
    Number.isFinite(Number(amount)) &&
    Number(amount) >= 0;
  const actions = editing
    ? [
        {
          id: 'edited',
          label: 'Approve edited amount',
          disabled: !valid,
          onSelect: () => {
            if (valid)
              onDecide(decision, { approved: true, amount: Number(amount) });
          },
        },
        {
          id: 'cancel-edit',
          label: 'Cancel edit',
          onSelect: () => setEdit(null),
        },
        {
          id: 'decline',
          label: 'Decline refund',
          onSelect: () => onDecide(decision, { approved: false }),
        },
      ]
    : [
        {
          id: 'approve',
          label: 'Approve refund',
          onSelect: () => onDecide(decision, { approved: true }),
        },
        {
          id: 'edit',
          label: 'Edit amount',
          onSelect: () =>
            setEdit({ decision, amount: String(decision.refund.amount) }),
        },
        {
          id: 'decline',
          label: 'Decline refund',
          onSelect: () => onDecide(decision, { approved: false }),
        },
      ];
  return (
    <ApprovalCard
      title="Refund approval required"
      disabled={disabled}
      actions={actions}
    >
      <dl>
        <dt>Amount</dt>
        <dd>${decision.refund.amount.toFixed(2)}</dd>
        <dt>Customer</dt>
        <dd>{decision.refund.customer_id}</dd>
        <dt>Reason</dt>
        <dd>{decision.refund.reason}</dd>
      </dl>
      {editing && (
        <label>
          Refund amount (USD)
          <input
            type="number"
            min="0"
            step="any"
            value={amount}
            disabled={disabled}
            onChange={(event) =>
              setEdit({ decision, amount: event.target.value })
            }
          />
        </label>
      )}
    </ApprovalCard>
  );
}
 
export function InterruptsDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof interruptsConnection>;
  readonly onReady: () => void;
}) {
  const [application, setApplication] = useState(() =>
    createConnectedApplication(connection)
  );
  const [resetting, setResetting] = useState(false);
  const [generation, setGeneration] = useState(0);
  const live = useRef(true);
  const snapshot = useAgent(application);
  useEffect(() => {
    onReady();
  }, [onReady]);
  useEffect(
    () => () => {
      void application.dispose();
    },
    [application]
  );
  useEffect(
    () => () => {
      live.current = false;
    },
    []
  );
  async function reset() {
    if (application.getSnapshot().busy || resetting) return;
    setResetting(true);
    await application.dispose();
    if (!live.current) return;
    setApplication(createConnectedApplication(connection));
    setGeneration((value) => value + 1);
    setResetting(false);
  }
  const status = snapshot.busy
    ? snapshot.creation === 'pending'
      ? 'Creating conversation…'
      : snapshot.activity === 'decision'
      ? 'Sending decision…'
      : 'Receiving response…'
    : snapshot.decision
    ? 'Awaiting refund approval.'
    : snapshot.outcome === 'success' && snapshot.canSubmit
    ? 'Response complete.'
    : snapshot.outcome === 'aborted'
    ? 'Stopped. Start a new conversation to continue.'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : 'Ready.';
  return (
    <ExampleLayout className="interrupts-demo">
      <header>
        <div>
          <h1>LangGraph interrupts</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy || resetting}
          onClick={() => void reset()}
        >
          New conversation
        </button>
      </header>
      <p>
        Review a simulated refund, then approve, edit, or decline it. No real
        charge is issued.
      </p>
      {snapshot.creation === 'idle' && (
        <div className="suggestions">
          {suggestions.map((suggestion) => (
            <button
              type="button"
              key={suggestion.label}
              disabled={snapshot.busy || resetting}
              onClick={() => void application.submit(suggestion.value)}
            >
              {suggestion.label}
            </button>
          ))}
        </div>
      )}
      <MessageList rows={snapshot.rows} label="Conversation" />
      {snapshot.decision && (
        <RefundApproval
          decision={snapshot.decision}
          disabled={snapshot.busy || resetting}
          onDecide={(decision, response) =>
            void application.decide(decision, response)
          }
        />
      )}
      <ExampleFooter>
        {snapshot.error && <p role="alert">{snapshot.error}</p>}
        <ChatInput
          key={generation}
          label="Message"
          placeholder="Describe a refund request…"
          disabled={resetting || !snapshot.canSubmit}
          busy={snapshot.busy}
          onStop={snapshot.busy ? () => void application.stop() : undefined}
          onSubmit={(text) => {
            if (!application.getSnapshot().canSubmit) return false;
            void application.submit(text);
          }}
        />
        <p role="status">{status}</p>
      </ExampleFooter>
    </ExampleLayout>
  );
}

The application validates the complete root payload before admitting an authored decision. Child observations do not authorize a root resume. This preview demonstrates ordinary current-conversation decisions; it does not add branch or checkpoint navigation.

application.ts
import type {
  AgentSession,
  AgentSnapshot,
  CompleteOutcome,
} from '@threadplane/core';
import {
  createMessageContent,
  type MessageRow,
} from '@threadplane/content/messages';
 
export interface InterruptSession extends Omit<AgentSession, 'getSnapshot'> {
  getSnapshot(): AgentSnapshot & {
    readonly interrupts: readonly { readonly value?: unknown }[];
  };
  resume(
    value: RefundResponse,
    options?: { readonly signal?: AbortSignal }
  ): Promise<CompleteOutcome>;
}
export interface Refund {
  readonly kind: 'refund_approval';
  readonly amount: number;
  readonly customer_id: string;
  readonly reason: string;
}
export interface RefundDecision {
  readonly refund: Refund;
}
export type RefundResponse =
  | { readonly approved: false }
  | { readonly approved: true; readonly amount?: number };
export interface InterruptsSnapshot {
  readonly creation: 'idle' | 'pending' | 'confirmed' | 'unconfirmed';
  readonly rows: readonly MessageRow[];
  readonly busy: boolean;
  readonly activity: 'message' | 'decision' | null;
  readonly canSubmit: boolean;
  readonly outcome: CompleteOutcome | null;
  readonly decision: RefundDecision | null;
  readonly error: string | null;
}
 
function ownData(
  value: unknown,
  keys: readonly string[]
): Record<string, unknown> | undefined {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return;
  const prototype = Object.getPrototypeOf(value);
  if (prototype !== Object.prototype && prototype !== null) return;
  const fields: Record<string, unknown> = Object.create(null);
  for (const key of keys) {
    const field = Object.getOwnPropertyDescriptor(value, key);
    if (!field || !Object.hasOwn(field, 'value')) return;
    fields[key] = field.value;
  }
  return fields;
}
function refundPayload(value: unknown): Refund | undefined {
  const data = ownData(value, ['kind', 'amount', 'customer_id', 'reason']);
  if (
    !data ||
    data['kind'] !== 'refund_approval' ||
    typeof data['amount'] !== 'number' ||
    !Number.isFinite(data['amount']) ||
    data['amount'] < 0 ||
    typeof data['customer_id'] !== 'string' ||
    typeof data['reason'] !== 'string'
  )
    return;
  return Object.freeze({
    kind: 'refund_approval',
    amount: data['amount'],
    customer_id: data['customer_id'],
    reason: data['reason'],
  });
}
function captureResponse(response: RefundResponse): RefundResponse | undefined {
  const data = ownData(response, ['approved']);
  if (!data || typeof data['approved'] !== 'boolean') return;
  const amount = Object.getOwnPropertyDescriptor(response, 'amount');
  if (!amount) return Object.freeze({ approved: data['approved'] });
  if (
    data['approved'] !== true ||
    !Object.hasOwn(amount, 'value') ||
    typeof amount.value !== 'number' ||
    !Number.isFinite(amount.value) ||
    amount.value < 0
  )
    return;
  return Object.freeze({ approved: true, amount: amount.value });
}
 
/** Owns one confirmed conversation and one complete root approval batch. */
export function createInterruptsApplication(options: {
  readonly createThread: (signal: AbortSignal) => Promise<string>;
  readonly sessionFactory: (threadId: string) => InterruptSession;
}) {
  const content = createMessageContent();
  const listeners = new Set<() => void>();
  let snapshot: InterruptsSnapshot = Object.freeze({
    creation: 'idle',
    rows: Object.freeze([]),
    busy: false,
    activity: null,
    canSubmit: true,
    outcome: null,
    decision: null,
    error: null,
  });
  let session: InterruptSession | undefined;
  let release: (() => void) | undefined;
  let capturedBatch:
    | ReturnType<InterruptSession['getSnapshot']>['interrupts']
    | undefined;
  let operation: AbortController | undefined;
  let disposed = false;
  let disposal: Promise<void> | undefined;
  function publish(update: Partial<InterruptsSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) notify();
  }
  function projectDecision() {
    if (!session || snapshot.busy || snapshot.outcome !== 'paused') return;
    const interrupts = session.getSnapshot().interrupts;
    if (interrupts === capturedBatch) return;
    capturedBatch = interrupts;
    const root =
      interrupts.length === 1 ? ownData(interrupts[0], ['value']) : undefined;
    const refund = root && refundPayload(root['value']);
    publish({
      decision: refund ? Object.freeze({ refund }) : null,
      canSubmit: false,
    });
  }
  function observe() {
    if (!disposed && session) {
      publish({ rows: content.project(session.getSnapshot()) });
      projectDecision();
    }
  }
  async function perform(
    activity: 'message' | 'decision',
    command: (
      owned: InterruptSession,
      signal: AbortSignal
    ) => Promise<CompleteOutcome>
  ) {
    const admitted = new AbortController();
    operation = admitted;
    capturedBatch = undefined;
    publish({
      busy: true,
      activity,
      canSubmit: false,
      decision: null,
      outcome: null,
      error: null,
    });
    const current = () =>
      !disposed && operation === admitted && !admitted.signal.aborted;
    try {
      if (!current()) return;
      if (!session) {
        publish({ creation: 'pending' });
        if (!current()) return;
        const threadId = await options.createThread(admitted.signal);
        if (!current()) return;
        session = options.sessionFactory(threadId);
        release = session.subscribe(observe);
        publish({
          creation: 'confirmed',
          rows: content.project(session.getSnapshot()),
        });
      }
      if (!current()) return;
      const outcome = await command(session, admitted.signal);
      if (!current()) return;
      publish({
        rows: content.project(session.getSnapshot()),
        outcome,
        canSubmit:
          outcome === 'success' &&
          session.getSnapshot().interrupts.length === 0,
        error: outcome === 'error' ? 'The LangGraph request failed.' : null,
      });
    } catch {
      if (!current()) return;
      publish({
        creation: session ? 'confirmed' : 'unconfirmed',
        outcome: 'error',
        canSubmit: false,
        error: 'The LangGraph request failed.',
      });
    } finally {
      if (!disposed && operation === admitted) {
        operation = undefined;
        publish({ busy: false, activity: null });
        projectDecision();
      }
    }
  }
  async function submit(text: string) {
    if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
      return;
    await perform('message', (owned, signal) => owned.submit(text, { signal }));
  }
  async function decide(decision: RefundDecision, response: RefundResponse) {
    if (disposed || snapshot.busy || snapshot.outcome !== 'paused' || !session)
      return;
    projectDecision();
    if (
      decision !== snapshot.decision ||
      !decision ||
      session.getSnapshot().interrupts !== capturedBatch
    )
      return;
    const captured = captureResponse(response);
    if (
      !captured ||
      disposed ||
      snapshot.busy ||
      snapshot.outcome !== 'paused' ||
      !session
    )
      return;
    projectDecision();
    if (
      disposed ||
      snapshot.busy ||
      snapshot.outcome !== 'paused' ||
      decision !== snapshot.decision ||
      session.getSnapshot().interrupts !== capturedBatch
    )
      return;
    const admittedBatch = capturedBatch;
    await perform('decision', (owned, signal) => {
      // Publishing pending state can synchronously replace the observed batch.
      // Reproject that pause without dispatching the superseded decision.
      if (owned.getSnapshot().interrupts !== admittedBatch)
        return Promise.resolve('paused');
      return owned.resume(captured, { signal });
    });
  }
  async function stop() {
    const admitted = operation;
    if (disposed || !admitted) return;
    admitted.abort();
    operation = undefined;
    capturedBatch = undefined;
    publish({
      busy: false,
      activity: null,
      canSubmit: false,
      decision: null,
      outcome: 'aborted',
      creation: session ? 'confirmed' : 'unconfirmed',
    });
    try {
      await session?.stop();
    } catch {
      /* Recovery still requires a new conversation. */
    }
  }
  function dispose(): Promise<void> {
    if (disposal) return disposal;
    disposed = true;
    operation?.abort();
    operation = undefined;
    release?.();
    content.dispose();
    listeners.clear();
    disposal = Promise.resolve()
      .then(() => session?.dispose())
      .then(() => undefined);
    return disposal;
  }
  return {
    getSnapshot: () => snapshot,
    subscribe(notify: () => void) {
      if (disposed) return () => undefined;
      listeners.add(notify);
      return () => {
        listeners.delete(notify);
      };
    },
    submit,
    decide,
    stop,
    dispose,
  };
}

The existing shared backend is used by default. A developer LangGraph target is configured through the workspace runtime settings, with credentials passed as SDK headers. Keep credentials out of messages and application code.

connection.ts
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { createInterruptsApplication } from './application';
 
export function interruptsConnection(
  configuration: RuntimeBridgeConfiguration
): { apiUrl: string; headers: Record<string, string> } {
  if (
    configuration.status === 'error' ||
    (configuration.status === 'configured' &&
      configuration.target.kind === 'ag-ui')
  )
    throw new Error('Runtime configuration failed.');
  const target =
    configuration.status === 'configured' &&
    configuration.target.kind === 'langsmith'
      ? configuration.target
      : null;
  return {
    apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
    headers: target ? { 'x-api-key': target.apiKey } : {},
  };
}
export function createConnectedApplication(
  connection: ReturnType<typeof interruptsConnection>
) {
  const client = new Client({
    apiUrl: connection.apiUrl,
    apiKey: null,
    defaultHeaders: connection.headers,
    callerOptions: { maxRetries: 0 },
  });
  return createInterruptsApplication({
    async createThread(signal) {
      const threadId = crypto.randomUUID();
      const created = await client.threads.create({
        threadId,
        ifExists: 'raise',
        signal,
      });
      if (created.thread_id !== threadId)
        throw new Error('Conversation creation was not confirmed.');
      return threadId;
    },
    sessionFactory: (threadId) =>
      createSession({
        assistantId: 'interrupts',
        threadId,
        apiUrl: connection.apiUrl,
        clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
      }),
  });
}

Build from source

From a Threadplane checkout with dependencies installed:

npx nx build cockpit-langgraph-interrupts-react
npx nx e2e cockpit-langgraph-interrupts-react

The isolated build installs local candidate packages and checks the emitted browser modules. Browser tests use real SDK HTTP and SSE against a local fixture. Docs, Code, and Run select this same React implementation.

Looking for something specific?