Interruption and steering

Your users can change direction mid-response. AI Transport gives you three patterns: steer the active Run with a follow-up prompt, cancel and re-prompt, or send alongside as a concurrent Run.

A user changes direction while the agent is still working: they narrow the scope, add a constraint, or press Stop and start over. The session is bidirectional, so the client can send a follow-up into the same active Run, cancel the Run and start a new one, or open a parallel Run on the same session.

Diagram showing session continuity during interruption

Start a Run with view.send(), then steer it by calling activeRun.steer() with a follow-up message:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

const activeRun = await session.view.send(UIMessageCodec.createUserMessage({
  id: crypto.randomUUID(),
  role: 'user',
  parts: [{ type: 'text', text: 'Plan a 3-day trip to Lisbon.' }],
}));

const { published, outcome } = activeRun.steer(UIMessageCodec.createUserMessage({
  id: crypto.randomUUID(),
  role: 'user',
  parts: [{ type: 'text', text: 'Make it 5 days, and keep it under £800.' }],
}));

How it works

Each turn becomes a Run on the session. While a Run is active, the client can do one of three things:

PatternWhat happensWhen to use it
Steer the active RunA new user message is published to the active Run; the agent picks it up on the next loop iteration.Quick correction or scope clarification while the agent is still working.
Cancel and re-promptThe active Run aborts; a new Run starts.The previous direction is wrong; the user wants to start over.
Send alongsideA second Run starts; both Runs stream in parallel.Quick follow-up that doesn't replace the in-flight reply.

Steering is the least destructive: it doesn't end the Run, start a new Run, or break the stream.

Steer the active Run

Call activeRun.steer(input) to send a steering message to the agent. The agent picks it up on the next loop iteration. A steering message will not start a new Run.

steer() returns two promises:

JavaScript

1

2

3

4

5

6

7

8

const { published, outcome } = activeRun.steer(UIMessageCodec.createUserMessage({
  id: crypto.randomUUID(),
  role: 'user',
  parts: [{ type: 'text', text: 'Also include vegan options.' }],
}));

const { serial } = await published;
const { consumed, runTerminalReason } = await outcome;
  • published resolves once the steering message has been published to the channel, carrying the Ably-assigned serial.
  • outcome resolves to { consumed: boolean, runTerminalReason?: RunEndReason } once the agent consumes the steering message, or once the Run ends without consuming it:
    • consumed: true: the agent saw the steering message before it sent its response. outcome resolves at the Run's next terminal event, whether the Run ends or suspends, so a message consumed before a suspend resolves rather than staying pending.
    • consumed: false: the Run ended before the agent saw the message.
    • runTerminalReason: how the Run ended ('complete', 'cancelled', or 'error'). Absent when the agent consumes the message at a run-suspend, since the Run has not ended.

On the agent side, Run.hasInput() controls the loop. It returns true while there's input the agent hasn't responded to (the original prompt, or a steering message from the previous iteration), and false once the agent has caught up:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

const run = session.createRun(invocation, { signal: req.signal });

// Drain run.view for the full conversation before starting. run.messages is
// only this Run's own turn, so feed run.view to the model instead.
while (run.view.hasOlder()) await run.view.loadOlder();
await run.start();

while (run.hasInput()) {
  const conversation = run.view.getMessages().map(({ message }) => message);
  const result = streamText({
    model: anthropic('claude-sonnet-4-20250514'),
    messages: await convertToModelMessages(conversation),
    abortSignal: run.abortSignal,
  });
  await run.pipe(result.toUIMessageStream());
}

await run.end({ reason: 'complete' });
await session.end();

Each pipe() records which steering messages the agent had seen before producing that response. The client uses those records to resolve each message's outcome when the Run ends.

Cancel the in-flight model call

The loop above only sees a steering message once the current model call finishes and hasInput() is checked again, so the steering message takes effect on the next response. To react immediately, pass the optional onSteer hook to createRun(). It fires the moment a steering message folds into the Run. The SDK does not interrupt the model call for you, so wire onSteer to abort the current model call; the loop then restarts with the steering message already in the conversation.

Give each model call its own AbortController, abort it from onSteer, and pass a signal that fires on either run cancellation or a steering message. The subtlety is that onSteer must abort only the model call, never the Run, and the loop has to tell a steering interruption apart from a genuine cancel before deciding how to end:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

let stepAbort = new AbortController();

const run = session.createRun(invocation, {
  signal: req.signal,
  // Abort only the current model call, never run.abortSignal, so a steering
  // message stops the response without cancelling the Run.
  onSteer: () => stepAbort.abort(),
});

while (run.view.hasOlder()) await run.view.loadOlder();
await run.start();

let outcome;
while (run.hasInput()) {
  stepAbort = new AbortController();
  const conversation = run.view.getMessages().map(({ message }) => message);
  const result = streamText({
    model: anthropic('claude-sonnet-4-20250514'),
    messages: await convertToModelMessages(conversation),
    // Abort on a genuine cancel (run.abortSignal) OR a steering message (stepAbort).
    abortSignal: AbortSignal.any([run.abortSignal, stepAbort.signal]),
  });

  const pipeResult = await run.pipe(result.toUIMessageStream());

  // run.pipe races only run.abortSignal, so a steering abort ends the stream
  // cleanly (pipe reason 'complete') while a real cancel ends it 'cancelled'.
  // A steering message interrupted this iteration if the stream ended clean, this
  // iteration's stepAbort fired, and the Run itself was not cancelled.
  const steerInterrupted =
    pipeResult.reason === 'complete' &&
    stepAbort.signal.aborted &&
    !run.abortSignal.aborted;

  if (steerInterrupted) {
    // Don't pass an interrupted iteration to vercelRunOutcome: an aborted
    // streamText rejects finishReason abort-like, which maps to 'cancelled' and
    // would end the Run. Swallow the rejection so it isn't unhandled, then loop.
    // hasInput() is now true, so the next iteration re-infers with the steering
    // message at the tail of the conversation.
    result.finishReason.catch(() => {});
    continue;
  }

  outcome = await vercelRunOutcome(pipeResult, result.finishReason);
  if (outcome.reason !== 'complete') break;
}

if (outcome?.reason === 'suspend') {
  await run.suspend();
} else {
  await run.end(outcome ?? { reason: 'complete' });
}
await session.end();

When onSteer aborts the model call, run.pipe() returns with the partial output published, run.hasInput() returns true because the steering message has folded into the conversation, and the next iteration streams a fresh response that includes it. onSteer takes no arguments and returns void; keep it synchronous and cheap, since it runs on the message-handling path.

Implement cancel and re-prompt

To replace the in-flight Run with a fresh one, cancel active Runs and then send the new message:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

import { useClientSession, useView } from '@ably/ai-transport/react';

function Chat() {
  const { session } = useClientSession();
  const view = useView();

  const handleSend = async (text) => {
    const active = view.runs().filter((run) => run.status === 'active');
    await Promise.all(active.map((run) => session.cancel(run.runId)));

    await view.send(UIMessageCodec.createUserMessage({
      id: crypto.randomUUID(),
      role: 'user',
      parts: [{ type: 'text', text }],
    }));
  };
}

session.cancel(runId) publishes a cancel signal. The agent's Run.abortSignal fires, the LLM stream stops, and the Run ends with reason 'cancelled'. The new message starts a clean Run.

Implement send-alongside

Send a new message without cancelling. Both Runs stream concurrently; each ClientRun carries its own runId and cancel(), and both Runs' outputs land on the same channel where they fold into the conversation tree by runId:

JavaScript

1

2

3

4

5

const followUp = await session.view.send(UIMessageCodec.createUserMessage({
  id: crypto.randomUUID(),
  role: 'user',
  parts: [{ type: 'text', text: 'Also include vegan options.' }],
}));

Cancel them independently with session.cancel(specificRunId) or activeRun.cancel().

Drive the Stop / Send toggle

session.view.runs() returns the visible RunInfo snapshots. Filter by status === 'active' to drive the Send / Stop toggle:

JavaScript

1

2

3

4

5

6

const { session } = useClientSession();
const isStreaming = session.view.runs().some((run) => run.status === 'active');

return isStreaming
  ? <StopButton onClick={() => cancelAll()} />
  : <SendButton onClick={handleSend} />;

Edge cases and unhappy paths

  • If a steering message arrives just as the agent finishes its last pipe(), outcome resolves to consumed: false. This is normal under load. Show the user that the message didn't reach the agent rather than dropping it silently.
  • Once a Run has ended, activeRun.steer() rejects both promises immediately and publishes nothing. Only allow steering while RunInfo.status === 'active'.
  • If a Run is 'suspended' (waiting on a tool approval, for example), a steering message the agent has not yet consumed keeps its outcome pending until the Run resumes and consumes it or ends. A message the agent already consumed has resolved as consumed: true. Don't block UI updates on a pending outcome.
  • consumed: true means the agent saw the steering message before it sent its response. It doesn't mean the LLM acted on it; that depends on the model, the system prompt, and what the agent passed in.
  • Cancel is asynchronous. A small tail of tokens arrives after session.cancel(runId) returns and before the agent's abortSignal fires. The view emits them on the cancelled Run; treat them as belonging to that Run, not the new one.
  • A Run cancelled while awaiting a tool result ends with 'cancelled'. Tool calls triggered before the cancel may still run on your server unless your handler honours the same AbortSignal.
  • A network drop on the client cancels nothing. The agent keeps streaming into the session. When the client reconnects, the response is still there. Check session.view.runs() on reconnect to decide whether to show Stop.
  • Send-alongside is rate-limited by your channel and any server-side concurrency you enforce. See concurrent turns.
  • 'cancelled' is reported on the Run's terminal RunEndReason. Don't infer cancellation from the absence of further tokens; subscribe to view.on('run', ...) and read the lifecycle event.

FAQ

When should I steer instead of cancel?

Steer when the existing direction is still useful and the user wants to refine it ("make it 5 days", "also include vegan options"). The agent keeps its tokens, its tool results, and its reasoning so far; the new input joins the conversation. Cancel when the existing direction is wrong and the user wants to start over.

What does consumed: false mean for the user?

The Run ended before the agent saw the steering message. Two common causes: the message arrived after the agent's final pipe(), or the Run was cancelled or errored first. It's up to your application whether to re-submit the steering message as a new user prompt for a new Run, or to drop it.

Can the agent ignore a steering message?

Yes. consumed: true only means the agent saw the message, not that the LLM acted on it. Whether the model heeds new mid-flight input depends on the model, the system prompt, and what the agent passed in.

Can I cancel one of several concurrent Runs without touching the others?

Yes. session.cancel(runId) only cancels the matching Run. Pull runIds from session.view.runs() to target the right one.

Does cancel work from a different device?

Yes. Cancel is a signal on the shared channel, so any client with publish capability can cancel a Run, not just the device that started it. The agent's onCancel hook decides whether to honour it; the default accepts every request. Scope it to the Run owner if a user should only cancel their own turns. See cancellation for the authorisation pattern.

Do steering messages persist in the conversation?

Yes. A steering message joins the conversation tree under the Run it steers, so every connected client sees it live and it survives reload. On rehydration run.view.getMessages() returns it in order alongside the original prompt and the agent's output, so the model sees the full exchange on the next turn.

What happens if I send several steering messages at once?

The next run.hasInput() call drains all pending steering messages together, and the following pipe() stamps every one of their codec-message-ids, resolving each message's outcome as consumed. The agent folds them all into a single model call rather than replying to each separately.

What if the agent endpoint is down when I send the follow-up?

The follow-up is published to the channel before your agent endpoint is called, so it is durable regardless of the endpoint's health. For a steering message that is all that is needed: the already-running agent picks it up on its next hasInput() check. For a new Run (cancel-and-reprompt or send-alongside) the client also POSTs to wake the agent; if that POST fails, the user input still sits on the channel, so retry the POST once the endpoint recovers and the agent drains history to find the trigger and start the Run.

  • Cancellation: cancel signals and agent-side authorisation.
  • Concurrent turns: multiple Runs in flight on the same session.
  • Runs: how steering fits the Run model.