REFERENCE
SDK Demos
How to use @asgard-js/core and @asgard-js/react, with demos you can try on the page.
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.
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 isisStopping.await stopGeneration(); // ← accepted (not "stopped")
// … isStopping === true …
// ← terminal event arrives on the stream; only now is sending allowed againFree 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
renderFooteror build your own send entrances.Two Rules for a Custom Footer
1. Gate Every Send Entrance on
isStoppingNot 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
canStopisConnectingis true for four unrelated things — the user's own turn, theRESET_CHANNELwelcome, a transcript rejoin, and an invisible nudge — and only the first is stoppable.canStopencodes 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 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)canForceStopis the escape hatch for an unresponsive agent and should never be reached normally. Pressing again passesforce: true, telling the backend to abandon the run.The
ChannelBusyErrorBackstopBeyond 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