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.
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
- 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.
- 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.
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
Free With the Default Footer
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.
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;
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
| Flag | Meaning |
|---|---|
isConnecting | The channel is busy — one of four causes; not the same as "stoppable" |
canStop | The in-flight run is a user-initiated one and can be stopped |
isStopping | A 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
Channeldirectly