Skip to main content

Stop Generation

Runs execute on the server. That means stopping generation is not "cut the local connection" — the old approach of calling AbortController.abort() only stopped watching: the agent kept running, burning tokens and writing the transcript, while the UI claimed it had stopped.

Stopping now sends a request asking the backend to suspend that run, and it completes asynchronously.

Stop Generation

Runs execute on the server, so stopping is a request asking the backend to suspend the run — not a local disconnect — and it completes asynchronously. stopGeneration() resolving means the request was accepted; the run is only actually stopped when the SSE stream reaches its terminal event. Between those two moments the channel is isStopping.

Try it

Send a message and watch the four flags above the input. They are bound to a real channel: during a run isConnecting and canStop light up, and the send button becomes a stop button.

Note: the public bot this demo site uses is an instant echo bot, so a run finishes almost immediately and the stop button is hard to actually catch — the flags often just blink. What this page shows is how the four flags are wired and what each one means; the full stop lifecycle is described below.

Free with the default footer

The built-in footer already handles all of this — no wiring needed. The rules below matter only if you replace the footer with renderFooter or build your own send entrances.

Two rules for a custom footer

  1. Gate every send entrance on isStopping, not just isConnecting. The old run has not finished; sending now leaves two concurrent runs writing to the same transcript. Keep the user's draft — do not clear it.
  2. Only show a stop control when canStop. isConnecting is true for four unrelated things — the user's own turn, the RESET_CHANNEL welcome, a transcript rejoin, and an invisible nudge — and only the first is stoppable. canStop encodes exactly that.
function CustomFooter() {
  const {
    sendMessage, isConnecting, isStopping,
    canStop, canForceStop, stopGeneration,
  } = useAsgardContext();

  const [value, setValue] = useState('');
  const canSend = !isConnecting && !isStopping && value.trim().length > 0;

  if (canStop || isStopping) {
    return (
      <button
        onClick={() => void stopGeneration?.({ force: canForceStop })
          .catch(() => undefined)}
        disabled={isStopping && !canForceStop}
      >
        {canForceStop ? 'Force stop' : isStopping ? 'Stopping…' : 'Stop'}
      </button>
    );
  }

  return (
    <button onClick={() => sendMessage?.({ text: value })} disabled={!canSend}>
      Send
    </button>
  );
}

The conversation survives a stop: the transcript is kept, the suspended turn is rolled back, and the next message continues the same conversation.

Loading chatbot...

Two Moments

stopGeneration() resolving means the request was accepted; the run is only actually stopped when the SSE stream reaches its terminal event — the same event a normal run ends with. Between those two moments the channel is isStopping.

await stopGeneration(); // ← accepted (not "stopped")
// … isStopping === true …
// ← terminal event arrives on the stream; only now is sending allowed again

The built-in footer already handles this whole lifecycle — no wiring needed. The rules below matter only if you replace the footer with renderFooter or build your own send entrances.

1. Gate Every Send Entrance on isStopping

Not just isConnecting. The old run has not finished; sending now leaves two concurrent runs writing to the same transcript. Keep the user's draft — do not clear it.

2. Only Show a Stop Control When canStop

isConnecting is true for four unrelated things — the user's own turn, the RESET_CHANNEL welcome, a transcript rejoin, and an invisible nudge — and only the first is stoppable. canStop encodes exactly that.

function CustomFooter() {
const {
sendMessage, isConnecting, isStopping,
canStop, canForceStop, stopGeneration,
} = useAsgardContext();

const [value, setValue] = useState("");
const canSend = !isConnecting && !isStopping && value.trim().length > 0;

const onStop = (): void => {
// A DOM handler cannot reject. On failure the SDK rolls the phase back to idle,
// so the control simply becomes pressable again — await it instead if you want
// to surface the error yourself.
void stopGeneration?.({ force: canForceStop }).catch(() => undefined);
};

if (canStop || isStopping) {
return (
<button onClick={onStop} disabled={isStopping && !canForceStop}>
{canForceStop ? "Force stop" : isStopping ? "Stopping…" : "Stop"}
</button>
);
}

return (
<button onClick={() => sendMessage?.({ text: value })} disabled={!canSend}>
Send
</button>
);
}

State Flags

FlagMeaning
isConnectingThe channel is busy — one of four causes; not the same as "stoppable"
canStopThe in-flight run is a user-initiated one and can be stopped
isStoppingA stop was accepted; the terminal event has not arrived yet
canForceStop~10s past an accepted stop with no terminal event — escalate to force stop (implies isStopping)

canForceStop is the escape hatch for an unresponsive agent and should never be reached normally. Pressing again passes force: true, telling the backend to abandon the run.

The ChannelBusyError Backstop

Beyond the UI gate, core's sendMessage() rejects outright while a run is in flight:

import { ChannelBusyError, isChannelBusyError } from "@asgard-js/core";

try {
await sendMessage({ text });
} catch (err) {
if (isChannelBusyError(err)) {
// The message was never sent and left no trace in the thread
}
}

It refuses before the optimistic user bubble is pushed, so a rejected send leaves no residue in the thread.

The Conversation Survives a Stop

The transcript is kept, the suspended turn is rolled back by the backend, and the next message continues the same conversation — no need to rebuild the channel.

See Also

  • Custom Footer — what to watch for when replacing the footer
  • Events — connection and message events
  • Headless — driving Channel directly