Interrupts pause graph execution and wait for input. @langchain/vue surfaces them on the root hook, lets you resume them imperatively, and can auto-resolve tool-interrupts via registered headless tool implementations.
Learn more: For HITL UX patterns, see the Human-in-the-loop documentation.
Runnable example: The
streamingpackage in the streaming cookbook hashitl:*scripts that pause on interrupts and resume with aCommand.
The root composable exposes the latest interrupt and the full list.
Each entry is an SDK Interrupt<TValue> object:
interface Interrupt<TValue = unknown> {
id?: string;
value?: TValue; // your interrupt payload lives here
}
Type the payload with the second generic to useStream:
<script setup lang="ts">
import { useStream } from "@langchain/vue";
import type { BaseMessage } from "@langchain/core/messages";
const stream = useStream<
{ messages: BaseMessage[] },
{ question: string } // InterruptType
>({
assistantId: "agent",
apiUrl: "http://localhost:2024",
});
function onResume() {
void stream.respond("Approved");
}
</script>
<template>
<div v-if="stream.interrupt">
<p>{{ stream.interrupt.value.question }}</p>
<button @click="onResume">Approve</button>
</div>
</template>
stream.interrupt is stream.interrupts[0] — the most recent root interrupt mirrored for UI convenience. It is not always the interrupt respond() would pick when target is omitted (see below).
Unlike React, Vue wraps reactive stream fields in ShallowRef /
ComputedRef. That adds one extra .value in <script setup> when
you read the interrupt payload.
| Location | Access pattern | Resolves to |
|---|---|---|
<template> |
stream.interrupt.value.question |
Payload field (question) |
<script setup> |
stream.interrupt.value?.value |
Full payload object |
<script setup> (recommended) |
computed(() => stream.interrupt.value?.value) |
Reactive payload |
In templates, Vue auto-unwraps refs on the stream handle, so the first
.value reads the SDK Interrupt.value payload. In script, the first
.value unwraps the Vue ComputedRef and returns the Interrupt
object; read the payload with a second .value:
<script setup lang="ts">
import { computed } from "vue";
const question = computed(() => stream.interrupt.value?.value?.question);
// Wrong in script: this is the Interrupt wrapper, not the payload.
// const question = stream.interrupt.value?.question;
</script>
Prefer a computed for anything you render or pass to handlers so the
payload stays reactive and you avoid repeating the double unwrap.
When using LangChain's
humanInTheLoopMiddleware,
the interrupt payload is a HITLRequest (action requests, review
configs, allowed decisions). Import the types from langchain and
unwrap the payload in script with stream.interrupt.value?.value:
<script setup lang="ts">
import { computed, ref } from "vue";
import { useStream } from "@langchain/vue";
import { AIMessage, HumanMessage, type HITLRequest, type HITLResponse } from "langchain";
import type { BaseMessage } from "@langchain/core/messages";
const stream = useStream<{ messages: BaseMessage[] }, HITLRequest>({
assistantId: "agent",
apiUrl: "http://localhost:2024",
});
const isProcessing = ref(false);
const hitlRequest = computed(() => stream.interrupt.value?.value as HITLRequest | undefined);
const actionRequests = computed(() => hitlRequest.value?.actionRequests ?? []);
async function onApprove() {
if (!hitlRequest.value) return;
isProcessing.value = true;
try {
const resume: HITLResponse = {
decisions: actionRequests.value.map(() => ({ type: "approve" })),
};
await stream.respond(resume);
} finally {
isProcessing.value = false;
}
}
</script>
<template>
<div v-for="msg in stream.messages" :key="msg.id">
<div v-if="HumanMessage.isInstance(msg)">{{ msg.text }}</div>
<div v-else-if="AIMessage.isInstance(msg) && msg.text">{{ msg.text }}</div>
</div>
<div v-if="hitlRequest && actionRequests.length > 0 && !isProcessing">
<!-- render ApprovalCard per actionRequests[i] -->
<button @click="onApprove">Approve</button>
</div>
</template>
Use hitlRequest (the unwrapped payload) for :disabled,
:placeholder, and conditional UI — not stream.interrupt itself,
which is always a ref object and stays truthy even when no interrupt
is pending.
Call stream.respond(value) when exactly one interrupt is pending:
void stream.respond({ approved: true });
When more than one interrupt can be active, pass an explicit target.
When options.interruptId is omitted, respond() walks stream.getThread()?.interrupts from newest to oldest and resumes the first entry whose interruptId has not already been resolved by a prior respond() call.
That list includes root and subgraph interrupts. It is not the same as stream.interrupt / stream.interrupts[0], which only mirror root-namespace interrupts.
| Surface | What it contains | Use for |
|---|---|---|
stream.interrupts |
Root-namespace interrupts ({ id, value }) |
Rendering root HITL UI |
stream.getThread()?.interrupts |
All protocol interrupts ({ interruptId, payload, namespace }) |
Targeting + namespace for respond() |
When several root interrupts are pending, target by id:
for (const intr of stream.interrupts.value) {
await stream.respond({ approved: true }, { interruptId: intr.id! });
}
Interrupts raised inside a subagent or nested graph carry a non-empty protocol namespace tuple (for example ["task:research"]). The server validates that tuple when you resume. Read namespace from the thread stream entry — do not guess it from UI state:
const thread = stream.getThread();
for (const entry of thread?.interrupts ?? []) {
await stream.respond(buildResponse(entry.payload), {
interruptId: entry.interruptId,
namespace: entry.namespace,
});
}
Pass both interruptId and namespace for subgraph interrupts; omitting namespace assumes root ([]) and the server will reject the resume if the pending interrupt lives in a subgraph.
respond(response, options?)options.interruptId |
Behavior |
|---|---|
| Omitted | Newest unresolved entry in getThread()?.interrupts. Safe when one interrupt is pending. |
interruptId set |
Resume that id at root (namespace: []). |
interruptId + namespace |
Resume that id in the given subgraph namespace. Required when the interrupt is not at root. |
options.config / options.metadata are folded into the run that services the resume — the same config / metadata you'd pass to submit().
respondAll(responsesById, options?)When a run pauses on several interrupts at the same checkpoint (e.g. parallel tool-authorization prompts), resume them in one command with respondAll. Sequential respond() calls would fail — the first resume starts a run, leaving the rest with no interrupted run to respond to.
// Distinct payloads per interrupt:
await stream.respondAll({
[interruptA.id]: { approved: true },
[interruptB.id]: { approved: false },
});
// Same payload to every pending interrupt:
await stream.respondAll(
Object.fromEntries(stream.interrupts.value.map((i) => [i.id!, { approved: true }])),
);
stream.stop() aborts the in-flight run. The transport
AbortController fires, the messages / toolCalls projections stop
receiving deltas, and values reconciles with the server's
authoritative snapshot. Safe to call unconditionally — when no run is
active it is a no-op.
<button :disabled="!stream.isLoading" @click="() => void stream.stop()">
Stop
</button>
Register tool implementations on the hook and the SDK will auto-resume matching interrupts with the handler's return value. The user never sees the tool interrupt — it resolves transparently:
import { useStream } from "@langchain/vue";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
const getCurrentLocation = tool(async () => ({ latitude: 47.61, longitude: -122.33 }), {
name: "get_current_location",
description: "Get the user's current location",
schema: z.object({}),
});
const stream = useStream({
assistantId: "deep-agent",
apiUrl: "http://localhost:2024",
tools: [getCurrentLocation],
onTool: (event) => {
if (event.type === "error") console.error(event.error);
},
});
onTool lifecycle events:
event.type |
Description |
|---|---|
start |
The SDK matched an interrupt to a registered tool. |
success |
Tool returned a value; the run is about to resume. |
error |
Tool threw; the error is surfaced to the server. |
Dedupe is automatic: the same interrupt observed twice (for example under <StrictMode>) is invoked once.
For advanced composition (custom interrupt routers, background workers, tests) the SDK also exports flushPendingHeadlessToolInterrupts, findHeadlessTool, handleHeadlessToolInterrupt, executeHeadlessTool, headlessToolResumeCommand, applyHeadlessToolResumeCommand, and the isHeadlessToolInterrupt / parseHeadlessToolInterruptPayload / filterOutHeadlessToolInterrupts predicates. These are the same primitives useStream uses internally to service tools / onTool.
Execute and resume all newly seen headless-tool interrupts from a values
Parses a headless-tool interrupt value from the graph. Accepts both
Strip headless-tool interrupts from a user-facing interrupt list.