Page actions

React AG-UI interrupts preview

Let a person review a fictional refund before the backend continues. AG-UI carries the pending interrupt; the application matches that pause and provides the decision controls.

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.

An action callback is not a resume protocol

ApprovalCard is a presentation component. The application translates its callbacks into the resume shape required by the current backend, including the observed interrupt identity. Keep the amount and decision tied to that pending pause, and wait for the resumed run before reporting completion. The example below uses a refund rather than the trip-planner domain to make approve, edit, and cancel easy to compare.

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.

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

Review a refund

Choose Run and send a fictional request containing a customer identifier, amount and reason. The agent streams an acknowledgement and pauses. The current Refund approval card shows those fields. Approve accepts the draft; Edit and approve submits the edited nonnegative amount; Cancel refund declines it. Each action addresses the current native interrupt explicitly.

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, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
  ApprovalCard,
  ChatInput,
  TextTranscript,
} from '@threadplane/react/chat';
import type { RefundApproval } from './approval-policy';
import type { createInterruptsApplication } from './application';
import {
  createConnectedApplication,
  type interruptsConnection,
} from './connection';
 
export function InterruptsDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof interruptsConnection>;
  readonly onReady: () => void;
}) {
  const [application] = useState(() => createConnectedApplication(connection));
  const [draft, setDraft] = useState('');
  const snapshot = useAgent(application);
  useEffect(() => {
    onReady();
  }, [onReady]);
  useEffect(
    () => () => {
      void application.dispose();
    },
    [application]
  );
  useEffect(() => {
    setDraft('');
  }, [snapshot.viewGeneration]);
  const status = snapshot.busy
    ? snapshot.activity === 'replacing'
      ? 'Starting new conversation…'
      : 'Processing refund…'
    : snapshot.canRespond
    ? 'Awaiting your decision.'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : snapshot.outcome === 'success'
    ? 'Refund decision complete.'
    : 'Ready.';
  return (
    <ExampleLayout className="streaming-demo">
      <header>
        <div>
          <h1>AG-UI approvals</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy}
          onClick={() => void application.newConversation()}
        >
          New conversation
        </button>
      </header>
      <p>
        Draft a fictional refund, review its amount, and approve, edit or cancel
        it. No real payment is issued. The conversation continues after each
        completed decision.
      </p>
      <section aria-label="Runtime connection">
        <h2>Runtime connection</h2>
        <p>{connection.label}</p>
      </section>
      <TextTranscript messages={snapshot.rows} label="Conversation" />
      {snapshot.approval && (
        <RefundCard
          key={snapshot.approval.pause}
          approval={snapshot.approval}
          application={application}
          disabled={snapshot.busy || !snapshot.canRespond}
        />
      )}
      <ExampleFooter>
        {snapshot.error && <p role="alert">{snapshot.error}</p>}
        <ChatInput
          label="Message"
          placeholder="Ask a fictional refund request…"
          value={draft}
          onValueChange={setDraft}
          disabled={!snapshot.canSubmit}
          busy={snapshot.busy}
          onStop={snapshot.busy ? () => void application.stop() : undefined}
          onSubmit={(text) => {
            if (!application.getSnapshot().canSubmit) return false;
            void application.submit(text);
            if (!application.getSnapshot().busy) return false;
            return true;
          }}
        />
        <p role="status">{status}</p>
      </ExampleFooter>
    </ExampleLayout>
  );
}
 
function RefundCard({
  approval,
  application,
  disabled,
}: {
  readonly approval: RefundApproval;
  readonly application: ReturnType<typeof createInterruptsApplication>;
  readonly disabled: boolean;
}) {
  const [edited, setEdited] = useState(String(approval.amount));
  const amount = edited.trim() ? Number(edited) : NaN;
  const validAmount = Number.isFinite(amount) && amount >= 0;
  return (
    <ApprovalCard
      title="Refund approval"
      disabled={disabled}
      actions={[
        {
          id: 'approve',
          label: 'Approve',
          onSelect: () => {
            void application.respond(approval, 'approve');
          },
        },
        {
          id: 'edit',
          label: 'Edit and approve',
          disabled: !validAmount,
          onSelect: () => {
            if (validAmount) void application.respond(approval, 'edit', amount);
          },
        },
        {
          id: 'cancel',
          label: 'Cancel refund',
          onSelect: () => {
            void application.respond(approval, 'cancel');
          },
        },
      ]}
    >
      <dl>
        <dt>Customer</dt>
        <dd>{approval.customerId}</dd>
        <dt>Amount</dt>
        <dd>${approval.amount.toFixed(2)}</dd>
        <dt>Reason</dt>
        <dd>{approval.reason}</dd>
      </dl>
      <label>
        Edited amount (USD)
        <input
          type="number"
          min="0"
          step="0.01"
          value={edited}
          disabled={disabled}
          onChange={(event) => setEdited(event.target.value)}
        />
      </label>
      {!validAmount && <p>Enter a finite amount of zero or more.</p>}
    </ApprovalCard>
  );
}

The native session owns execution, messages, state, run identity and the pause token. React observes it through useAgent. TextTranscript renders ordinary root text literally; ChatInput and ApprovalCard present app-authored actions. The card does not manufacture a decision or own the command.

Match the current pause

An approval is available only after the current run has settled with one native root interrupt. Its thread/run identities, opaque pause token, interrupt ID and refund metadata must agree with the terminal and the exact submitted conversation prefix. A saved refund identifier or an older decision in graph state cannot authorize another action.

approval-policy.ts
import type { Session, TextTranscriptRow } from '@threadplane/ag-ui';
export type NativeSnapshot = ReturnType<Session['getSnapshot']>;
export interface OwnedTurn {
  readonly threadId: string;
  readonly runId: string;
  readonly human: TextTranscriptRow;
  readonly prefix: readonly TextTranscriptRow[];
}
export interface RefundApproval {
  readonly threadId: string;
  readonly sourceRunId: string;
  readonly pause: Parameters<Session['resume']>[0];
  readonly interruptId: string;
  readonly amount: number;
  readonly customerId: string;
  readonly reason: string;
  readonly rows: readonly TextTranscriptRow[];
}
function same(left: TextTranscriptRow, right: TextTranscriptRow) {
  return (
    left.id === right.id &&
    left.role === right.role &&
    left.content === right.content
  );
}
function ordinaryRows(
  transcript: NativeSnapshot['transcript'],
  valid: () => boolean
) {
  const rows: TextTranscriptRow[] = [],
    ids = new Set<string>();
  for (const message of transcript) {
    if (!valid()) return;
    const { id, role, content, subagentRunId } = message;
    if (
      typeof id !== 'string' ||
      !id ||
      ids.has(id) ||
      (role !== 'user' && role !== 'assistant') ||
      typeof content !== 'string' ||
      !content ||
      typeof subagentRunId === 'string' ||
      (role === 'assistant' && !!message.toolCalls?.length) ||
      !valid()
    )
      return;
    ids.add(id);
    rows.push(Object.freeze({ id, role, content }));
  }
  return valid() ? Object.freeze(rows) : undefined;
}
function read(snapshot: NativeSnapshot, valid: () => boolean) {
  if (!valid()) return;
  const status = snapshot.status;
  if (!valid()) return;
  const run = snapshot.run;
  if (!valid()) return;
  const decision = snapshot.decision;
  if (!valid()) return;
  const subagents = snapshot.subagents;
  if (!valid()) return;
  const transcript = snapshot.transcript;
  if (!valid() || run?.legacyInterrupt || subagents.length) return;
  const rows = ordinaryRows(transcript, valid);
  return rows && valid() ? { status, run, decision, rows } : undefined;
}
export function captureTurn(
  snapshot: NativeSnapshot,
  threadId: string,
  previous: string | undefined,
  prefix: readonly TextTranscriptRow[],
  valid = () => true
): OwnedTurn | undefined {
  const state = read(snapshot, valid);
  if (
    !state ||
    state.status !== 'running' ||
    state.decision ||
    !state.run?.id ||
    state.run.id === previous ||
    state.run.outcome !== undefined ||
    !threadId ||
    state.rows.length !== prefix.length + 1 ||
    !prefix.every((row, index) => same(row, state.rows[index])) ||
    !valid()
  )
    return;
  const human = state.rows.at(-1)!;
  if (human.role !== 'user' || !valid()) return;
  return Object.freeze({
    threadId,
    runId: state.run.id,
    human,
    prefix: Object.freeze([...prefix]),
  });
}
/** Read own data fields only; borrowed protocol objects are never frozen. */
function data(value: unknown, key: string): unknown {
  if (
    value === null ||
    typeof value !== 'object' ||
    (Object.getPrototypeOf(value) !== Object.prototype &&
      Object.getPrototypeOf(value) !== null)
  )
    return;
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
  return descriptor && 'value' in descriptor ? descriptor.value : undefined;
}
function refund(interrupt: unknown, now: number) {
  const id = data(interrupt, 'id'),
    expiry = data(interrupt, 'expiresAt'),
    child = data(interrupt, 'subagentRunId');
  if (
    child !== undefined ||
    typeof id !== 'string' ||
    !id ||
    (expiry !== undefined &&
      (typeof expiry !== 'string' ||
        !Number.isFinite(Date.parse(expiry)) ||
        Date.parse(expiry) <= now))
  )
    return;
  const raw = data(data(data(interrupt, 'metadata'), 'langgraph'), 'raw');
  const kind = data(raw, 'kind'),
    amount = data(raw, 'amount'),
    customerId = data(raw, 'customer_id'),
    reason = data(raw, 'reason');
  if (
    kind !== 'refund_approval' ||
    typeof amount !== 'number' ||
    !Number.isFinite(amount) ||
    amount < 0 ||
    typeof customerId !== 'string' ||
    !customerId.trim() ||
    typeof reason !== 'string' ||
    !reason.trim()
  )
    return;
  return { id, amount, customerId, reason };
}
export function captureApproval(
  snapshot: NativeSnapshot,
  turn: OwnedTurn,
  valid = () => true
): RefundApproval | undefined {
  const state = read(snapshot, valid);
  if (
    !state ||
    state.status !== 'idle' ||
    state.run?.id !== turn.runId ||
    state.run.outcome !== 'paused' ||
    !valid()
  )
    return;
  const decision = state.decision,
    terminal = state.run.terminal;
  if (
    decision?.kind !== 'native' ||
    !decision.id ||
    decision.attempt ||
    decision.sourceRunId !== turn.runId ||
    decision.interrupts.length !== 1 ||
    terminal?.type !== 'RUN_FINISHED' ||
    terminal.threadId !== turn.threadId ||
    terminal.runId !== turn.runId ||
    terminal.outcome?.type !== 'interrupt' ||
    terminal.outcome.interrupts.length !== 1 ||
    state.rows.length !== turn.prefix.length + 2 ||
    !turn.prefix.every((row, index) => same(row, state.rows[index])) ||
    !same(state.rows.at(-2)!, turn.human) ||
    state.rows.at(-1)!.role !== 'assistant' ||
    !valid()
  )
    return;
  const now = Date.now();
  const selected = refund(decision.interrupts[0], now),
    finished = refund(terminal.outcome.interrupts[0], now);
  if (
    !selected ||
    !finished ||
    selected.id !== finished.id ||
    selected.amount !== finished.amount ||
    selected.customerId !== finished.customerId ||
    selected.reason !== finished.reason ||
    !valid()
  )
    return;
  return Object.freeze({
    threadId: turn.threadId,
    sourceRunId: turn.runId,
    pause: decision.id,
    interruptId: selected.id,
    amount: selected.amount,
    customerId: selected.customerId,
    reason: selected.reason,
    rows: state.rows,
  });
}
export function confirmResume(
  snapshot: NativeSnapshot,
  approval: RefundApproval,
  runId: string,
  valid = () => true
): readonly TextTranscriptRow[] | undefined {
  const state = read(snapshot, valid);
  if (
    !state ||
    !runId ||
    runId === approval.sourceRunId ||
    state.status !== 'idle' ||
    state.decision ||
    state.run?.id !== runId ||
    state.run.outcome !== 'success' ||
    !valid()
  )
    return;
  const terminal = state.run.terminal;
  if (
    terminal?.type !== 'RUN_FINISHED' ||
    terminal.threadId !== approval.threadId ||
    terminal.runId !== runId ||
    (terminal.outcome !== undefined && terminal.outcome.type !== 'success') ||
    (terminal.outcome?.type === 'success' &&
      !!terminal.outcome.pendingToolCallIds?.length) ||
    state.rows.length !== approval.rows.length + 1 ||
    !approval.rows.every((row, index) => same(row, state.rows[index])) ||
    state.rows.at(-1)!.role !== 'assistant' ||
    !valid()
  )
    return;
  return state.rows;
}

The first Send creates a local conversation UUID lazily. After a confirmed decision, another refund can use that same UUID and full canonical history. While paused, Send stays disabled. The app admits one action synchronously, rechecks the current pause before resume, and ignores stale card callbacks and competing clicks.

application.ts
import {
  projectTextTranscript,
  type Session,
  type TextTranscriptRow,
} from '@threadplane/ag-ui';
import type { CompleteOutcome } from '@threadplane/core';
import {
  captureTurn,
  captureApproval,
  confirmResume,
  type RefundApproval,
  type NativeSnapshot,
  type OwnedTurn,
} from './approval-policy';
 
export interface InterruptsSnapshot {
  readonly threadId: string | null;
  readonly native: NativeSnapshot | undefined;
  readonly rows: readonly TextTranscriptRow[];
  readonly busy: boolean;
  readonly approval: RefundApproval | undefined;
  readonly canRespond: boolean;
  readonly canSubmit: boolean;
  readonly activity: 'idle' | 'running' | 'replacing';
  readonly outcome: CompleteOutcome | null;
  readonly error: string | null;
  readonly viewGeneration: number;
}
 
/** Owns lifetime and admission; the native session owns all protocol execution. */
export function createInterruptsApplication(options: {
  readonly sessionFactory: (threadId: string) => Session;
}) {
  type Primary = {
    session: Session;
    threadId: string;
    closed: boolean;
    release?: () => void;
    unsafe: boolean;
    confirmed: readonly TextTranscriptRow[];
    previousRunId?: string;
    turn?: OwnedTurn;
    approval?: RefundApproval;
    intent?: 'submit' | 'resume';
    resumeRunId?: string;
  };
  const listeners = new Set<() => void>(),
    cleanups = new Set<Promise<void>>();
  let primary: Primary | undefined;
  let operation: AbortController | undefined;
  let disposed = false,
    disposal: Promise<void> | undefined;
  let snapshot: InterruptsSnapshot = Object.freeze({
    threadId: null,
    native: undefined,
    rows: Object.freeze([]),
    busy: false,
    approval: undefined,
    canRespond: false,
    canSubmit: true,
    activity: 'idle',
    outcome: null,
    error: null,
    viewGeneration: 0,
  });
  function publish(update: Partial<InterruptsSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) {
      try {
        notify();
      } catch {
        /* A view cannot interrupt native execution or cleanup. */
      }
    }
  }
  function current(selected: Primary) {
    return (
      !disposed &&
      primary === selected &&
      !selected.closed &&
      !operation?.signal.aborted
    );
  }
  function close(selected: Primary) {
    if (selected.closed) return Promise.resolve();
    selected.closed = true;
    try {
      selected.release?.();
    } catch {
      /* Revoked observation cannot prevent native cleanup. */
    }
    const cleanup = Promise.resolve()
      .then(() => selected.session.dispose())
      .then(
        () => undefined,
        () => undefined
      );
    cleanups.add(cleanup);
    void cleanup.then(() => cleanups.delete(cleanup));
    return cleanup;
  }
  function detach() {
    const previous = primary;
    primary = undefined;
    return previous ? close(previous) : Promise.resolve();
  }
  function read(selected: Primary, valid: () => boolean) {
    if (!valid()) return;
    const root = selected.session.getSnapshot();
    if (!valid()) return;
    const transcript = root.transcript;
    if (!valid()) return;
    const subagents = root.subagents;
    if (!valid()) return;
    const decision = root.decision;
    if (!valid()) return;
    const run = root.run;
    if (!valid()) return;
    const status = root.status;
    if (!valid()) return;
    const blocked =
      status === 'error' ||
      (decision !== undefined &&
        (decision.kind !== 'native' || decision.interrupts.length !== 1)) ||
      !!subagents.length ||
      !!run?.legacyInterrupt ||
      (run?.terminal?.type === 'RUN_FINISHED' &&
        run.terminal.outcome?.type === 'success' &&
        !!run.terminal.outcome.pendingToolCallIds?.length) ||
      transcript.some(
        (message) =>
          (message.role !== 'user' && message.role !== 'assistant') ||
          typeof message.subagentRunId === 'string' ||
          (message.role === 'assistant' && !!message.toolCalls?.length)
      );
    return valid() ? { root, blocked } : undefined;
  }
  function capture(selected: Primary, valid: () => boolean) {
    const state = read(selected, valid);
    if (!state || !valid()) return;
    selected.unsafe ||= state.blocked;
    if (!selected.turn && selected.intent === 'submit') {
      const turn = captureTurn(
        state.root,
        selected.threadId,
        selected.previousRunId,
        selected.confirmed,
        valid
      );
      if (!valid()) return;
      if (turn) selected.turn = turn;
    }
    const approval =
      selected.intent === 'submit' && selected.turn && !selected.unsafe
        ? captureApproval(state.root, selected.turn, valid)
        : undefined;
    if (
      selected.intent === 'resume' &&
      selected.approval &&
      state.root.status === 'running' &&
      state.root.run?.id &&
      state.root.run.id !== selected.approval.sourceRunId &&
      state.root.decision?.kind === 'native' &&
      state.root.decision.id === selected.approval.pause &&
      state.root.decision.sourceRunId === selected.approval.sourceRunId &&
      state.root.decision.attempt?.runId === state.root.run.id &&
      valid()
    )
      selected.resumeRunId ??= state.root.run.id;
    const confirmed =
      selected.intent === 'resume' &&
      selected.approval &&
      selected.resumeRunId &&
      !selected.unsafe
        ? confirmResume(
            state.root,
            selected.approval,
            selected.resumeRunId,
            valid
          )
        : undefined;
    if (!valid()) return;
    const rows = projectTextTranscript(state.root.transcript, snapshot.rows);
    return valid()
      ? { native: state.root, rows, approval, confirmed }
      : undefined;
  }
  function observe(selected: Primary) {
    const valid = () => current(selected);
    try {
      const result = capture(selected, valid);
      if (!result || !valid()) return;
      publish({
        native: result.native,
        rows: result.rows,
        ...(selected.unsafe
          ? { canSubmit: false, canRespond: false, approval: undefined }
          : {}),
      });
    } catch {
      if (valid()) {
        selected.unsafe = true;
        publish({
          canSubmit: false,
          canRespond: false,
          approval: undefined,
          error:
            'The refund result could not be confirmed. Start a new conversation.',
        });
      }
    }
  }
  function install(valid: () => boolean) {
    const threadId = crypto.randomUUID();
    const session = options.sessionFactory(threadId);
    const selected: Primary = {
      session,
      threadId,
      closed: false,
      unsafe: false,
      confirmed: [],
    };
    if (!valid()) {
      void close(selected);
      return;
    }
    primary = selected;
    const release = session.subscribe(() => observe(selected));
    selected.release = release;
    if (selected.closed) release();
    if (!valid() || primary !== selected) {
      void close(selected);
      return;
    }
    return selected;
  }
  function admit(activity: InterruptsSnapshot['activity'], execution: boolean) {
    const admitted = new AbortController();
    operation = admitted;
    const valid = () =>
      !disposed && operation === admitted && !admitted.signal.aborted;
    publish({
      busy: true,
      canSubmit: false,
      canRespond: false,
      activity,
      ...(execution ? { outcome: null, error: null } : {}),
    });
    return { admitted, valid };
  }
  function finish(admitted: AbortController) {
    if (!disposed && operation === admitted) {
      operation = undefined;
      publish({ busy: false, activity: 'idle' });
    }
  }
  async function execute(text: string) {
    if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
      return;
    const before = primary;
    const initialValid = () =>
      !disposed &&
      !snapshot.busy &&
      snapshot.canSubmit &&
      primary === before &&
      !before?.closed;
    let state: ReturnType<typeof read>;
    try {
      state = before && read(before, initialValid);
    } catch {
      if (initialValid())
        publish({
          canSubmit: false,
          error: 'The refund session is unavailable. Start a new conversation.',
        });
      return;
    }
    if (!initialValid()) return;
    if (before && (before.unsafe || state?.blocked || state?.root.decision)) {
      publish({ canSubmit: false });
      return;
    }
    const { admitted, valid } = admit('running', true);
    try {
      if (!valid()) return;
      const selected = primary ?? install(valid);
      if (!selected || !valid()) return;
      publish({ threadId: selected.threadId });
      const ownedValid = () => valid() && current(selected);
      const root = read(selected, ownedValid);
      if (!root || !ownedValid()) return;
      if (root.blocked || selected.unsafe) {
        selected.unsafe = true;
        publish({ canSubmit: false });
        return;
      }
      selected.previousRunId = root.root.run?.id;
      selected.turn = undefined;
      selected.intent = 'submit';
      if (!ownedValid()) return;
      const pending = selected.session.submit(text, {
        signal: admitted.signal,
      });
      void pending.catch(() => undefined);
      if (!ownedValid()) return;
      observe(selected);
      const outcome = await pending;
      if (!ownedValid()) return;
      const result = capture(selected, ownedValid);
      if (!result || !ownedValid()) return;
      const safe =
        outcome === 'paused' && !!result.approval && !selected.unsafe;
      selected.approval = safe ? result.approval : undefined;
      publish({
        native: result.native,
        rows: result.rows,
        outcome,
        canSubmit: false,
        canRespond: safe,
        approval: selected.approval,
        error: safe
          ? null
          : 'The current refund outcome could not be confirmed. Start a new conversation.',
      });
    } catch {
      if (valid())
        publish({
          canSubmit: false,
          outcome: 'error',
          error: 'The AG-UI request failed. Start a new conversation.',
        });
    } finally {
      finish(admitted);
    }
  }
  async function respond(
    approval: RefundApproval,
    action: 'approve' | 'edit' | 'cancel',
    amount?: number
  ) {
    const selected = primary;
    if (
      disposed ||
      snapshot.busy ||
      !snapshot.canRespond ||
      !selected ||
      selected.closed ||
      selected.unsafe ||
      snapshot.approval !== approval ||
      selected.approval !== approval ||
      !selected.turn ||
      !['approve', 'edit', 'cancel'].includes(action) ||
      (action === 'edit' &&
        (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0))
    )
      return;
    const initialValid = () =>
      !disposed &&
      !snapshot.busy &&
      snapshot.canRespond &&
      primary === selected &&
      !selected.closed &&
      snapshot.approval === approval;
    function matches(valid: () => boolean) {
      if (!valid()) return false;
      const root = selected!.session.getSnapshot();
      if (!valid()) return false;
      const fresh = captureApproval(root, selected!.turn!, valid);
      return (
        !!fresh &&
        fresh.pause === approval.pause &&
        fresh.interruptId === approval.interruptId &&
        fresh.sourceRunId === approval.sourceRunId &&
        fresh.amount === approval.amount &&
        fresh.customerId === approval.customerId &&
        fresh.reason === approval.reason &&
        fresh.rows.length === approval.rows.length &&
        fresh.rows.every(
          (row, index) =>
            row.id === approval.rows[index].id &&
            row.role === approval.rows[index].role &&
            row.content === approval.rows[index].content
        ) &&
        valid()
      );
    }
    try {
      if (!matches(initialValid)) {
        if (initialValid()) {
          selected.unsafe = true;
          publish({
            canRespond: false,
            approval: undefined,
            error:
              'This approval is no longer current. Start a new conversation.',
          });
        }
        return;
      }
    } catch {
      if (initialValid()) {
        selected.unsafe = true;
        publish({
          canRespond: false,
          approval: undefined,
          error:
            'The approval could not be confirmed. Start a new conversation.',
        });
      }
      return;
    }
    if (!initialValid()) return;
    const { admitted, valid } = admit('running', true);
    const ownedValid = () => valid() && current(selected);
    try {
      if (!matches(ownedValid) || selected.unsafe || !ownedValid()) {
        if (ownedValid()) {
          selected.unsafe = true;
          publish({
            approval: undefined,
            canRespond: false,
            error:
              'This approval is no longer current. Start a new conversation.',
          });
        }
        return;
      }
      selected.intent = 'resume';
      selected.resumeRunId = undefined;
      const payload =
        action === 'cancel'
          ? { approved: false }
          : action === 'edit'
          ? { approved: true, amount: amount! }
          : { approved: true };
      if (selected.unsafe || !ownedValid()) return;
      const pending = selected.session.resume(
        approval.pause,
        [{ interruptId: approval.interruptId, status: 'resolved', payload }],
        { signal: admitted.signal }
      );
      void pending.catch(() => undefined);
      if (!ownedValid()) return;
      observe(selected);
      const outcome = await pending;
      if (!ownedValid()) return;
      const result = capture(selected, ownedValid);
      if (!result || !ownedValid()) return;
      const safe =
        outcome === 'success' && !!result.confirmed && !selected.unsafe;
      if (safe) selected.confirmed = result.confirmed!;
      else selected.unsafe = true;
      selected.approval = undefined;
      publish({
        native: result.native,
        rows: result.rows,
        approval: undefined,
        canRespond: false,
        outcome,
        canSubmit: safe,
        error: safe
          ? null
          : 'The refund result could not be confirmed. Start a new conversation.',
      });
    } catch {
      if (ownedValid()) {
        selected.unsafe = true;
        selected.approval = undefined;
        publish({
          canRespond: false,
          approval: undefined,
          outcome: 'error',
          error: 'The refund request failed. Start a new conversation.',
        });
      }
    } finally {
      finish(admitted);
    }
  }
  async function newConversation() {
    if (disposed || snapshot.busy) return;
    const { admitted, valid } = admit('replacing', false);
    const cleanup = detach();
    try {
      if (!valid()) return;
      publish({
        threadId: null,
        native: undefined,
        approval: undefined,
        canRespond: false,
        rows: Object.freeze([]),
        outcome: null,
        error: null,
        viewGeneration: snapshot.viewGeneration + 1,
      });
      if (!valid()) return;
      await cleanup;
      if (valid()) publish({ canSubmit: true });
    } finally {
      finish(admitted);
    }
  }
  async function stop() {
    if (disposed || !operation) return;
    operation.abort();
    operation = undefined;
    const selected = primary,
      cleanup = detach();
    publish({
      busy: false,
      activity: 'idle',
      canSubmit: false,
      canRespond: false,
      approval: undefined,
      outcome: 'aborted',
      error: 'The operation stopped. Start a new conversation.',
    });
    try {
      await selected?.session.stop();
    } catch {
      /* Detached work has no publication authority. */
    }
    await cleanup;
  }
  function dispose(): Promise<void> {
    if (disposal) return disposal;
    disposed = true;
    operation?.abort();
    operation = undefined;
    void detach();
    listeners.clear();
    disposal = (async () => {
      do {
        await Promise.resolve();
        await Promise.all([...cleanups]);
      } while (cleanups.size);
    })();
    return disposal;
  }
  return {
    getSnapshot: () => snapshot,
    subscribe(notify: () => void) {
      if (disposed) return () => undefined;
      listeners.add(notify);
      return () => {
        listeners.delete(notify);
      };
    },
    submit: execute,
    respond,
    newConversation,
    stop,
    dispose,
  };
}

New conversation clears the card, transcript and draft; its next Send creates a fresh UUID. Stop cancels active work. Leaving the page revokes the old session. Unsupported interrupts, tools, child agents, errors and uncertain results require New. There is no automatic replay, reconnect or saved-history restoration control; the local UUID does not claim durable server persistence.

Use the existing backend

Shared runtime uses the examples site's native route, /ag-ui/interrupts/agent/native. Runtime settings can supply an authored Developer runtime AG-UI endpoint. Its address stays out of the preview's visible UI.

connection.ts
import { createSession } from '@threadplane/ag-ui';
import { createInterruptsApplication } from './application';
// 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';
export function interruptsConnection(
  configuration: RuntimeBridgeConfiguration
): {
  url: string;
  label: 'Shared runtime' | 'Developer runtime';
} {
  if (
    configuration.status === 'error' ||
    (configuration.status === 'configured' &&
      configuration.target.kind === 'langsmith')
  )
    throw new Error('Runtime configuration failed.');
  const target =
    configuration.status === 'configured' &&
    configuration.target.kind === 'ag-ui'
      ? configuration.target
      : null;
  return {
    url:
      target?.endpoint ??
      new URL('/ag-ui/interrupts/agent/native', window.location.origin).href,
    label: target ? 'Developer runtime' : 'Shared runtime',
  };
}
export function createConnectedApplication(
  connection: ReturnType<typeof interruptsConnection>
) {
  return createInterruptsApplication({
    sessionFactory(threadId) {
      const session = createSession({ threadId, url: connection.url });
      try {
        const snapshot = session.getSnapshot();
        if (
          !snapshot ||
          !Array.isArray(snapshot.transcript) ||
          !Array.isArray(snapshot.subagents) ||
          ![
            'getSnapshot',
            'subscribe',
            'submit',
            'resume',
            'stop',
            'dispose',
          ].every(
            (method) =>
              typeof session[method as keyof typeof session] === 'function'
          )
        )
          throw new Error('Runtime session is unavailable.');
      } catch {
        if (typeof session?.dispose === 'function') {
          try {
            void Promise.resolve(session.dispose()).catch(() => undefined);
          } catch {
            /* Rejected candidates have no authority. */
          }
        }
        throw new Error('Runtime session is unavailable.');
      }
      return session;
    },
  });
}

The existing refund graph keeps its models, prompts and approval behavior. Internal structured extraction is excluded from conversation events; the acknowledgement still streams. A dedicated native bridge counterpart emits structured interrupt outcomes and accepts native resume entries. The original Angular endpoint retains its legacy interrupt contract.

native_agent.py
from ag_ui_langgraph import LangGraphAgent
 
 
class NativeRefundAgent(LangGraphAgent):
    """Serve native decisions while the ordinary endpoint keeps its legacy contract."""
 
    def __init__(self, *, name, graph, description=None, config=None, **kwargs):
        kwargs['enable_legacy_on_interrupt_event'] = False
        kwargs['emit_interrupt_outcome'] = True
        super().__init__(
            name=name, graph=graph, description=description, config=config, **kwargs,
        )
server.py
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
from .native_agent import NativeRefundAgent
 
agent = LangGraphAgent(name="interrupts", graph=graph)
app = FastAPI(title="cockpit-ag-ui-interrupts")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
native_agent = NativeRefundAgent(name="interrupts", graph=graph)
add_langgraph_fastapi_endpoint(app, native_agent, path="/agent/native")
 
 
@app.get("/ok")
def ok() -> dict:
    return {"ok": True}

Source build and verification

From a Threadplane checkout with dependencies installed:

npx nx build cockpit-ag-ui-interrupts-react
npx nx e2e cockpit-ag-ui-interrupts-react

The isolated build installs local candidate artifacts with locked dependencies and strict types. Python tests exercise the actual compiled graph and bridge with frozen model responses. Browser tests use the installed AG-UI SDK over local HTTP/SSE, covering approve, edit, cancel, canonical history, protected outcomes, developer routing and cancellation of held streams. Docs, Code and Run select this same React implementation. AG-UI Streaming, Tool Views, JSON Render and Subagents and nine LangGraph topics also have React previews; other topics remain pending.

Looking for something specific?