Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~35 min· topic 2 of 6

Topic 14.2

Long-Running Work

In one line

A run that lasts minutes must not depend on one browser connection. The run lives on the server, the screen is rebuilt from server state when the user comes back, a resumable stream rejoins a live run, and a notification (found by feature detection) tells people who left. Finished and failed runs need their own return screens.

Think of it like this

Leaving a shirt at the dry cleaner. You hand it over, get a ticket and go home. The cleaning carries on whether you stand at the counter or not. When you come back, the shop looks up your ticket and says "ready", "still in the machine", or "we found a stain, call us". A long agent run needs the same arrangement: the work belongs to the shop (the server), and the ticket (a run id) lets any screen ask about it later.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Run
One execution of the agent loop on the server, with an id and a status.
Resumable stream
A stream whose events are buffered on the server, so a client can reconnect later and receive them again.
resume: true
A useChat option. On mount it asks the server for a live stream of this chat and joins it if there is one.
204 No Content
An HTTP status with no body. For resume it means "there is no live run to join".
Replay
Sending a joining client the events that already happened, from the start, before the live ones.
Polling
Asking the server for the current state every few seconds instead of keeping a connection open.
Transient data part
A data part with transient: true. It reaches onData but is not stored in the message.
Notification API
The browser feature that shows a system notification. It needs permission from the user.
Feature detection
Checking that a browser feature exists and is allowed before using it, instead of assuming.

Step by step

01The run lives on the server, the screen can come and go

The first live example keeps a run in a Map on the server. The run pushes four steps, 80 ms apart, into a list of events, and it does so whether or not anyone is connected. A connection is just a viewer that replays the list and then follows it.

The first screen shows steps 1 and 2, then the scripted user "closes the tab" by calling stop(). The server notices the viewer left (its stream was cancelled), removes it from the viewers, and keeps working. A second screen mounts with resume: true.

The run lives on the server, the screen can come and godiagram
Rendering diagram…

02What the returning screen gets

Look at the output: the second screen logs 1 of 4 to 4 of 4, not only the missing steps. This SDK replays from the start (resumeStreamIsReplay: true). The text in the message is reset when the replay begins, so the learner sees the whole answer exactly once.

The progress lines are a transient data part. They reach onData but are not saved in the message. The last line shows the message holds only text.

03When there is nothing live: 204 and a blank screen

The second live example starts after the work is over. Two runs are stored on the server: one finished, one failed. For each, the resume route answers 204, because no live stream exists any more.

A screen that trusts resume alone is empty, which tells the user nothing. The same screen with a stored copy shows the finished answer, or "Stopped at step 3: Payment provider timed out. Kept so far: …" for the failed run.

04Telling people who are not looking

The third live example separates the decision from the browser. chooseChannel is a plain function of three facts (is the feature there, what is the permission, is the tab hidden), so every combination can be listed and tested.

The code also asks the real browser and writes the answer on the page (the line that starts "Here:"), not in the console, because it differs from browser to browser. In our checker the Notification object exists but its permission is denied, and Notification.requestPermission() resolves to default without showing a prompt. That is why the in-page banner is the part you can rely on.

05The server half, with a real store (not live)

The non-live block shows the two routes a real app adds: the POST that starts the run and registers its stream, and the GET at the default resume address that rejoins it. Notice where the active stream id is stored, and the 204 branches.

Try it yourself

  1. 1

    Slow the run so you can see the catch-up

    In the first example, change await sleep(80) to await sleep(200) and run again.

    The second screen still replays steps 1 and 2 at once (they are buffered), then waits for step 3 as it happens live. Replay is fast, live is slow.

  2. 2

    Close the tab on a different step

    Change part.data.done === 2 to part.data.done === 3 in the first example.

    Now the first screen shows three steps before it "leaves", and the returned screen still shows one to four.

  3. 3

    Show the return state in words

    In the second example, change describe so a finished run starts with "Finished while you were away: ". Run it.

    The fix is one string, but it changes how the user reads the screen: they now know the work happened without them.

Code & diagrams

Closing the tab does not stop the run

A scripted visitor starts the run and, after step 2, closes the tab (stop() aborts the connection). The next screen comes back event-driven, right after the abort, and the run is still going on the server.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

first screen: 1 of 4
first screen: 2 of 4
-- the user closes the tab --
(server) a screen asks to resume: run still going
returned screen: 1 of 4
returned screen: 2 of 4
returned screen: 3 of 4
returned screen: 4 of 4
returned screen finished, kept in the message: text
Coming back to a run that already ended

Three returns in a row, each starting when the previous one ends. The first shows the bug: resume alone leaves the screen empty once the run is over.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server) resume asked for refund-1042: no live run, answering 204
finished run, resume only: the screen is empty
(server) resume asked for refund-1042: no live run, answering 204
finished run, plus saved copy: "Refund of Rs 349 issued for ORD-1042."
(server) resume asked for refund-1043: no live run, answering 204
failed run, plus saved copy: "Stopped at step 3: Payment provider timed out. Kept so far: Found the order. Checked the return window."
Telling the user it finished, if the browser allows

The five listed cases are fixed. The line that starts "Here:" on the page shows what your own browser reports, and it may differ from ours. That is why the code decides from the facts it finds.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

no Notification API, tab hidden -> title change only
permission not asked yet, tab hidden -> title change, and ask permission on the next click
permission denied, tab hidden -> title change only
permission granted, tab hidden -> system notification
permission granted, tab visible -> in-page banner (the user is looking at the page)
finished; the in-page banner is shown
Resumable routes with a real store (not live)ts
// app/api/chat/route.ts: start a run, and register its stream so it can be rejoined
import { after } from "next/server";
import { createResumableStreamContext } from "resumable-stream"; // needs Redis (REDIS_URL)
import { ToolLoopAgent, createAgentUIStreamResponse, generateId } from "ai";

const streamContext = createResumableStreamContext({ waitUntil: after });

export async function POST(req: Request) {
  const { id, messages } = await req.json();
  const streamId = generateId();
  await saveChat(id, { activeStreamId: streamId, messages }); // your database
  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
    // a copy of the SSE stream goes to the resumable store; the browser still gets the live one
    consumeSseStream: ({ stream }) => streamContext.createNewResumableStream(streamId, () => stream).then(() => {}),
    onUIMessageStepEnd: ({ responseMessage }) => saveMessage(id, responseMessage), // keep progress even if the run crashes
  });
}

// app/api/chat/[id]/stream/route.ts: the address useChat({ resume: true }) calls
import { UI_MESSAGE_STREAM_HEADERS } from "ai";

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const chat = await loadChat(id);
  if (!chat?.activeStreamId) return new Response(null, { status: 204 }); // nothing running
  const stream = await streamContext.resumeExistingStream(chat.activeStreamId);
  // null: that stream is already finished, undefined: no such stream
  if (!stream) return new Response(null, { status: 204 });
  return new Response(stream, { headers: UI_MESSAGE_STREAM_HEADERS });
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

The resume route does not exist

In the second example, change the status in the stream route from 204 to 404 (replace new Response(null, { status: 204 }) with new Response("Not found", { status: 404 })).

terminal
$ Console (the (server) line still says 204 because only the status was changed)
── what you'll see ──
(server) resume asked for refund-1042: no live run, answering 204
finished run, resume only: useChat error: Not found
finished run, resume only: the screen is empty
(server) resume asked for refund-1042: no live run, answering 204
finished run, plus saved copy: useChat error: Not found
finished run, plus saved copy: "Refund of Rs 349 issued for ORD-1042."
(server) resume asked for refund-1043: no live run, answering 204
failed run, plus saved copy: useChat error: Not found
failed run, plus saved copy: "Stopped at step 3: Payment provider timed out. Kept so far: Found the order. Checked the return window."

Myth vs fact

Myth

resume: true brings back the whole conversation after a reload.

Fact

It only rejoins a stream that is still live on the server. Once the run is over, the answer is 204 and nothing arrives. Load saved messages from your own storage.

Myth

If I close the tab, the run stops.

Fact

Only if you built it that way. A run on the server continues unless something stops it. Closing the tab ends one connection. Whether the work goes on is your design decision, and it matters when the work changes real things.

Myth

Browser notifications are how I tell users about finished jobs.

Fact

They are one optional channel. They can be missing, blocked, or denied, and the user may never be asked. The in-page state, built from stored run status, is the reliable part.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The default resume URL is <api>/<chat id>/stream, with the chat id URL-encoded. Chat ids of . or .. are refused by the transport. If your routes are laid out differently, prepareReconnectToStreamRequest on the transport lets you return another api, headers or credentials.

  • ▸

    A replaying resume re-sends every event. For a long run that can be megabytes, and it makes the returning screen redraw a lot. If your server can send only what the client has not seen, build the transport with resumeStreamIsReplay: false. Then your endpoint must send only the continuation.

  • ▸

    Two tabs on the same chat will both resume and both show the run. If the second tab can also press Start, you can get two runs. Use an idempotency key (the chat id plus the user's message id) so the server starts a run once.

  • ▸

    Stop and resume pull in opposite directions. Closing a connection is not cancelling the run. If the user presses Stop, send an explicit cancel request that the server records, or the run will keep going and the next resume will rejoin it (Topic 14.3).

Remember this

  1. 1

    The problem this solves: A normal chat answer lives inside one HTTP connection. If the tab closes, the phone locks or the Wi-Fi drops, the connection ends and the answer is lost, or worse, the agent is half way through refunding an order and nobody knows. With work that takes minutes, that happens to real users all the time. This topic separates the run from the connection and shows how to come back to it.

  2. 2

    Three things, kept apart. The run is the work: the agent loop, running on your server. The connection is one HTTP request that carries the run's events to a browser. The screen is React state in one tab. Short chats let all three share one fate. For long work you design so that the run outlives the connection, and the screen can be rebuilt from the server at any time.

  3. 3

    The run needs a home that is not the connection. The server must record that a run exists (an id, a status such as running, finished or failed, and the steps done so far), and it must keep producing events when nobody is listening. On a serverless platform, a request handler has a maximum duration that depends on your plan, so a job of many minutes is usually handed to a background job or workflow system, and the request only starts it. The browser then follows the job.

  4. 4

    Three ways to follow a run. (1) A resumable stream: the live events are buffered on the server, and a returning browser reconnects and replays them. (2) Polling: the browser asks GET /api/runs/<id> every few seconds. It is plain and survives anything, but it is delayed and costs requests. (3) Push: the server tells a service worker when the run ends (a larger feature, outside this course). Most products combine them: a stream for the live view, and a stored status for everything else.

  5. 5

    What the AI SDK gives you. useChat({ id, resume: true }) makes the chat, on mount, send GET <api>/<chat id>/stream. A 204 answer means there is nothing live to join. A stream answer is read like the original response. It also tries again when the tab becomes visible after a dropped connection. chat.resumeStream() does the same on demand. By default the resumed stream replays from the beginning (resumeStreamIsReplay is true), and the message is reset when the replay starts, so the user does not see doubled text.

  6. 6

    The server half is yours. The SDK only knows how to ask. You must store each chat's active stream, buffer its events (the resumable-stream package does this on top of Redis publish/subscribe), and answer the GET. A buffered stream is kept only for a limited time (that package's default for a finished stream is 24 hours). After that, or once the run has ended, the answer is 204, and a screen that relies on resume alone comes back blank. Always store the finished message (or the failure and what was done) and load it when resume has nothing to join.

  7. 7

    Return screens, one per ending. *Still running*: join the stream and show "Step 3 of 4". *Finished while you were away*: show the result and a note. *Failed while you were away*: show where it stopped, what was kept, and a Retry button. *Never started or expired*: say so. Write each sentence before you write the code, because each one is a design decision.

  8. 8

    Notifications are optional, so detect them. The browser Notification API may be missing, may be blocked by the page's sandbox or by the user, and browsers increasingly show the permission prompt only after a user gesture such as a click. Check typeof Notification !== "undefined" and Notification.permission (granted, denied or default). Notify only when document.hidden is true, because a user who is looking at the page does not need a pop-up. Always have an in-page fallback: a banner with role="status" and a changed document.title. Keep private details out of a system notification, since lock screens show them.

Explain it without notes

01

Why should an agent run on the server outlive the browser connection?

02

What does useChat({ resume: true }) do on mount, and what does a 204 mean?

03

Why can a screen that relies only on resume come back blank?

04

How do you decide whether to show a system notification when a run finishes?

Practice

01

Write describeReturn(run) for all four endings: running (with step and of), finished, failed (with error and kept) and expired. Each returns one sentence for the user.

02

A product manager wants a system notification on every finished run. List three conditions under which you will not show one, and what you show instead.

Trade-offs

  • ↔

    A resumable stream gives a live feel on return but needs buffering infrastructure (Redis pub/sub) and a careful expiry story. Polling a stored status is simpler and survives every failure, but the screen updates only every few seconds.

  • ↔

    Replaying from the start is simple and always correct, but costs bandwidth on long runs. Sending only what is missing saves bandwidth but needs the server to track a position for each client.

Done when you can

  • I can explain why the run, the connection and the screen are three separate things.

  • I can use resume: true or resumeStream() and say what a 204 means.

  • I can build a return screen for running, finished, failed and expired runs.

  • I can store a finished or failed result so a returning user is never shown a blank screen.

  • I can choose a notification channel by feature detection and always keep an in-page fallback.