Stop Workflow
Gracefully cancel a running agent workflow using a hook signal.
Use this pattern when you need to gracefully stop a running agent from the outside — for example, a "Stop" button in a chat UI or an admin cancellation endpoint. The workflow listens for a stop signal via a hook while the agent runs, and breaks out of the loop when the signal arrives.
Pattern
Create a hook with a known token (the run ID). Listen for a stop signal in a non-blocking .then(). Check the flag before each agent turn and break if signaled. Close the stream cleanly on exit.
Simplified
import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";
export const stopHook = defineHook({
schema: z.object({ reason: z.string().optional() }),
});
declare function processQuery(args: { query: string }): Promise<string>; // @setup
export async function stoppableAgent(messages: ModelMessage[]) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
let stopRequested = false;
const hook = stopHook.create({ token: `stop:${workflowRunId}` });
hook.then(() => { stopRequested = true; });
const agent = new DurableAgent({
model: "anthropic/claude-haiku-4.5",
tools: {
processQuery: {
description: "Process a query",
inputSchema: z.object({ query: z.string() }),
execute: processQuery,
},
},
});
const result = await agent.stream({
messages,
writable: getWritable<UIMessageChunk>(),
prepareStep: () => {
if (stopRequested) return { stop: true };
return {};
},
});
return { messages: result.messages, stopped: stopRequested };
}Full Implementation
import { DurableAgent } from "@workflow/ai/agent";
import { defineHook, getWritable, getWorkflowMetadata } from "workflow";
import { z } from "zod";
import type { ModelMessage, UIMessageChunk } from "ai";
// Hook to signal the workflow to stop
export const stopHook = defineHook({
schema: z.object({
reason: z.string().optional(),
}),
});
// Step: Search the web
async function searchWeb({ query }: { query: string }) {
"use step";
await new Promise((resolve) => setTimeout(resolve, 1000));
return { results: [`Result for "${query}"`] };
}
// Step: Analyze data
async function analyzeData({ data }: { data: string }) {
"use step";
await new Promise((resolve) => setTimeout(resolve, 800));
return { analysis: `Analysis of: ${data}` };
}
// Step: Write the final close marker to the stream
async function closeStream() {
"use step";
const writable = getWritable<UIMessageChunk>();
const writer = writable.getWriter();
try {
await writer.write({ type: "finish" } as UIMessageChunk);
} finally {
writer.releaseLock();
}
await writable.close();
}
export async function stoppableAgent(messages: ModelMessage[]) {
"use workflow";
const { workflowRunId } = getWorkflowMetadata();
const writable = getWritable<UIMessageChunk>();
// Listen for stop signal using a non-blocking hook
let stopRequested = false;
let stopReason: string | undefined;
const hook = stopHook.create({ token: `stop:${workflowRunId}` });
hook.then(({ reason }) => {
stopRequested = true;
stopReason = reason;
});
const agent = new DurableAgent({
model: "anthropic/claude-haiku-4.5",
instructions: "You are a research assistant. Search and analyze data as needed.",
tools: {
searchWeb: {
description: "Search the web for information",
inputSchema: z.object({ query: z.string() }),
execute: searchWeb,
},
analyzeData: {
description: "Analyze a piece of data",
inputSchema: z.object({ data: z.string() }),
execute: analyzeData,
},
},
maxSteps: 20,
});
const result = await agent.stream({
messages,
writable,
preventClose: true,
prepareStep: ({ stepNumber }) => {
// Check stop flag before each agent step
if (stopRequested) {
return { stop: true };
}
return {};
},
});
// Clean up: close the stream
await closeStream();
return {
messages: result.messages,
stopped: stopRequested,
stopReason,
stepsCompleted: result.steps.length,
};
}API Route to Trigger Stop
import { stopHook } from "@/workflows/stoppable-agent";
export async function POST(
request: Request,
{ params }: { params: Promise<{ runId: string }> }
) {
const { runId } = await params;
const { reason } = await request.json();
await stopHook.resume(`stop:${runId}`, {
reason: reason || "User requested stop",
});
return Response.json({ success: true });
}Client Stop Button
"use client";
export function StopButton({ runId }: { runId: string }) {
const handleStop = async () => {
await fetch(`/api/chat/${runId}/stop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "User clicked stop" }),
});
};
return (
<button type="button" onClick={handleStop}>
Stop Agent
</button>
);
}Key APIs
"use workflow"— declares the orchestrator function"use step"— declares step functions with retriesdefineHook()— type-safe hook for the stop signalgetWorkflowMetadata()— access the run ID for deterministic hook tokensgetWritable()— stream output and close cleanly on stopDurableAgent—prepareStepcallback to check stop flag before each step