Skip to main content

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.

  1. 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
  2. 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.

  3. 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>
    );
    }
  4. 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.

  5. 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.

  6. 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.

  7. See Also​

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

Ready to get started?

Want to see how Asgard fits your team? Talk to us.

Contact us