Skip to main content
Interruptions are messages sent to an agent while it is already working. For a coding agent, this is essential: you notice the agent going off in the wrong direction, and you want to add missing context to get it back on track without waiting for the current task to finish. Restate lets you implement interruptions with cancellation signals. Cancelling a running invocation causes it to terminally fail and raise an error at the next durable step, while still letting the handler run further durable actions such as cleanup and notifying the agent orchestrator. Cancellation automatically propagates through sub-invocations, giving you something similar to stack unwinding with exceptions, just distributed.

How it works

The pattern uses two services:
  • CodingAgent — a Virtual Object, one per agent session. It holds the conversation history and the invocation ID of any running task.
  • CodingTask — a long-running Service that performs the actual work (a lengthy LLM call, or a chain of them).
When a new user message arrives:
  1. The agent’s message handler loads the conversation history from the Virtual Object state.
  2. If a task is already running, it cancels that invocation and waits for the cleanup to finish.
  3. It sends a new task to the CodingTask service and persists the new invocation ID.
  4. Inside CodingTask, the cancellation surfaces as a terminal error at the next Restate await. The task catches it, notifies the orchestrator for durable cleanup, and re-raises so Restate records the invocation as cancelled.

How does Restate help?

  • Durable cancellation signals: Cancellation is a first-class, durable signal that propagates through nested invocations automatically.
  • Cleanup always runs: Terminal errors surface at the next Restate await, giving the handler a chance to run durable cleanup steps before finishing.
  • Concurrency control per session: Virtual Objects queue concurrent requests per key, so interruptions are processed one at a time without races.
  • Observability: The Restate UI shows the cancelled invocation side-by-side with the one that replaced it.

Example

1. Interrupt on every new message

The orchestrator’s message handler loads the conversation history, cancels any in-flight task, waits for its cleanup to finish, then dispatches a fresh task and stores its invocation ID for the next interruption. ctx.cancel sends a durable cancellation signal and ctx.attach blocks until the target has finished its cleanup; the cancelled invocation terminating with a TerminalError is the expected outcome:
agent.ts
/** Receive a user message. A new message interrupts any ongoing task. */
message: restate.createObjectHandler(
  { input: schema(ChatMessage) },
  async (ctx: ObjectContext, msg: ChatMessage) => {
    // (1) Access state of the Virtual Object
    const messages = (await ctx.get<ModelMessage[]>("messages")) ?? [];
    messages.push({ role: "user", content: msg.content });

    // (2) Interrupt the ongoing task and wait for its cleanup to finish.
    // The cancelled invocation finishes with a TerminalError; swallow it.
    const currentTaskId = await ctx.get<string>("current_task_id");
    if (currentTaskId) {
      const id = InvocationIdParser.fromString(currentTaskId);
      ctx.cancel(id);
      try {
        await ctx.attach(id).orTimeout(30_000);
      } catch (err) {
        if (!(err instanceof TerminalError)) throw err;
      }
    }

    // (3) Start executing the new task
    const handle = ctx
      .serviceSendClient<CodingTask>({ name: "CodingTask" })
      .runTask({ agentId: ctx.key, messages });

    // (4) Store the handle to the task and persist the updated history
    const invocationId = await handle.invocationId;
    ctx.set("current_task_id", invocationId);
    ctx.set("messages", messages);
  },
),

2. Catch the cancellation inside the task

On the other side, the cancellation surfaces as a CancelledError at the next ctx.run. A try/catch around the work lets you run durable cleanup before re-raising:
agent.ts
/**
 * Long-running coding task. If interrupted, the cancellation surfaces
 * as TerminalError at the next Restate await — we catch it, run durable
 * cleanup, and re-raise so Restate records the invocation as cancelled.
 */
runTask: async (ctx: Context, inp: TaskInput) => {
  try {
    // Three sequential LLM calls. Each is a Restate await, so a
    // cancellation can land between any of them and unwind cleanly.
    const convo: ModelMessage[] = [...inp.messages];

    const plan = await ctx.run("LLM: plan", () =>
      llmCall(convo, "Outline a high-level plan."),
    );
    convo.push({ role: "assistant", content: plan.text });

    const draft = await ctx.run("LLM: draft", () =>
      llmCall(convo, "Write a draft implementation."),
    );
    convo.push({ role: "assistant", content: draft.text });

    const polish = await ctx.run("LLM: polish", () =>
      llmCall(convo, "Polish it into a final version."),
    );

    ctx
      .objectSendClient<CodingAgent>({ name: "CodingAgent" }, inp.agentId)
      .appendMessage({ content: polish.text });
  } catch (err) {
    if (err instanceof TerminalError) {
      const content =
        err instanceof CancelledError
          ? "[task cleanup ran after cancellation]"
          : "[task cleanup ran after error]";
      ctx
        .objectSendClient<CodingAgent>({ name: "CodingAgent" }, inp.agentId)
        .appendMessage({ content });
    }
    throw err;
  }
},
Install Restate and launch it:
restate-server
Get the example:
restate example typescript-restate-agent-examples && cd typescript-restate-agent-examples/interrupt-regenerate
npm install
Start the agent service with your API key:
OPENAI_API_KEY=sk-proj-... npm run dev
Register the services with Restate:
restate deployments register http://localhost:9080 --force --yes # dev only: overrides previous registrations
Happy path — one message, one full response:
curl localhost:8080/restate/call/CodingAgent/alice/message \
  --json '{"content":"Write me a small todo CLI in TypeScript."}'

# Wait a few seconds, then:
curl localhost:8080/restate/call/CodingAgent/alice/getHistory
Interruption path — fire a first message, then interrupt before it finishes:
# Fire-and-forget the first message
curl localhost:8080/restate/send/CodingAgent/bob/message \
  --json '{"content":"Build a Fastify app with user auth."}'

# Immediately interrupt with new context
curl localhost:8080/restate/call/CodingAgent/bob/message \
  --json '{"content":"Actually, use Hono instead of Fastify."}'

curl localhost:8080/restate/call/CodingAgent/bob/getHistory
Open the Restate UI at http://localhost:9070 to inspect the invocations — the first CodingTask.runTask shows status cancelled, and the second completed.
This pattern is implementable with any of our SDKs and any AI SDK. If you need help with a specific SDK, please reach out to us via Discord or Slack.