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