ESC
Type to search...
S
Soli Docs

Watching an AI Agent Think: Live Progress with Server-Sent Events

An AI agent that does real work — plan a task, call a few tools, read some sources, synthesize an answer — is slow. Not slow like a slow query; slow like ten-to-sixty seconds of wall-clock while several model calls happen back to back. If all the user sees is a spinner, two things go wrong: they assume it's broken and refresh (kicking off the whole expensive run again), or they leave (and you keep burning tokens for nobody).

The fix isn't a fancier spinner. It's telling the user what's happening, as it happens: "planning…", "searching 2 of 4…", "writing the summary…", then the answer. That's a one-way stream of progress events from the server to the browser — which is exactly what Server-Sent Events are for, and Soli now streams them with a single sse(...) block.

This post builds a small research agent that streams its progress live. No WebSocket handshake, no client framework, no polling.

A Soli controller's sse() block on the right streams named events — plan, tool, tool, token — over a text/event-stream connection to the browser on the left, which renders them as a live progress list: planned 4 steps, two completed tool calls, and 'synthesizing answer…' in progress.
One sse() block on the server pushes each step to the browser's EventSource the moment it happens — the user watches the agent work instead of staring at a spinner.

Why SSE (and not WebSockets)

Progress reporting is one-directional: the server talks, the browser listens. SSE fits that shape perfectly and costs almost nothing:

  • It's plain HTTP with Content-Type: text/event-stream — works through proxies and needs no upgrade handshake.
  • The browser's built-in EventSource parses framed events and reconnects automatically if the connection drops.
  • You get named events (event: status) and multi-line data for free.

Reach for WebSockets when you need the browser to talk back mid-stream; reach for Live View when you want server-rendered reactive UI. For "show me progress," SSE wins on simplicity.

The shape

A controller action returns sse(req) with a block. Soli holds the connection open and flushes each event as you emit it:

def run(req)
  sse(req) do |out|
    out.emit("hello", "status")   # event: status\n data: hello\n\n
    out.emit("world")             # a plain data: event
  end
end

out.emit(data, event?) sends one event; multi-line data is split into multiple data: lines per the SSE spec. Crucially, out.emit returns false when the client has disconnected — so an agent can bail out instead of running (and paying for) three more model calls no one will read.

The agent

Here's a research agent: it asks a model to break the question into steps, runs each step, then synthesizes a final answer — emitting an event at every stage. Because HTTP.* returns the complete response (it buffers, rather than streaming tokens), we stream progress per step, which is exactly the granularity a user cares about.

# app/controllers/agent_controller.sl

def run(req)
  question = params["q"] ?? "What changed in the last release?"

  sse(req) do |out|
    out.emit("Planning the work…", "status")
    steps = plan_steps(question)          # one quick model call -> a list
    out.emit(steps.to_json, "plan")

    findings = []
    total = len(steps)
    i = 0
    for step in steps
      i = i + 1
      # The user closed the tab? Stop now — don't pay for the rest.
      return unless out.emit("Step #{i}/#{total}: #{step}", "status")
      findings.push(run_step(step))
    end

    out.emit("Synthesizing the answer…", "status")
    answer = synthesize(question, findings)
    out.emit(answer, "result")
    out.emit("done", "end")
  end
end

The model calls themselves are ordinary HTTP. Because the Anthropic API needs custom headers (x-api-key, anthropic-version), use HTTP.request(method, url, headers, body) — the convenience HTTP.post(url, body, opts) ignores a headers option:

# Defaulting to the latest Claude models for AI work; Sonnet keeps a
# multi-step agent cheap, swap in claude-opus-4-8 for the hardest reasoning.
def ask_claude(prompt)
  res = HTTP.request("POST", "https://api.anthropic.com/v1/messages", {
    "x-api-key": getenv("ANTHROPIC_API_KEY"),
    "anthropic-version": "2023-06-01",
    "content-type": "application/json"
  }, {
    "model": "claude-sonnet-4-6",
    "max_tokens": 1024,
    "messages": [{ "role": "user", "content": prompt }]
  }.to_json)

  body = JSON.parse(res["body"])
  return body["content"][0]["text"]
end

def plan_steps(question)
  raw = ask_claude("Break this into 2-4 short research steps, one per line:\n" + question)
  return raw.split("\n").map(fn(s) s.trim()).filter(fn(s) !s.blank?)
end

def run_step(step)    return ask_claude("Research this and answer concisely:\n" + step) end
def synthesize(q, f)  return ask_claude("Question: #{q}\n\nNotes:\n" + f.join("\n\n") + "\n\nWrite the final answer.") end

Wire the route:

# config/routes.sl
get("/agent/run", "agent#run")

That's the whole server side. No queue, no background worker, no WebSocket session — the request is the stream.

The browser

EventSource does the heavy lifting. Subscribe to the named events and append to a log; close the connection when the end event arrives so it doesn't auto-reconnect and re-run the agent:

<form id="ask">
  <input name="q" placeholder="Ask the agent…" size="40">
  <button>Run</button>
</form>
<ul id="log"></ul>
<div id="answer"></div>

<script>
document.getElementById("ask").addEventListener("submit", (ev) => {
  ev.preventDefault();
  const q = ev.target.q.value;
  const log = document.getElementById("log");
  log.innerHTML = "";

  const es = new EventSource("/agent/run?q=" + encodeURIComponent(q));

  es.addEventListener("status", (e) => addLine("⏳ " + e.data));
  es.addEventListener("plan",   (e) => addLine("📋 " + JSON.parse(e.data).join(" · ")));
  es.addEventListener("result", (e) => { document.getElementById("answer").textContent = e.data; });
  es.addEventListener("end",    ()  => es.close());   // stop — don't reconnect
  es.onerror = () => es.close();

  function addLine(t) {
    const li = document.createElement("li");
    li.textContent = t;
    log.appendChild(li);
  }
});
</script>

Now the page narrates the run in real time: Planning the work… → Step 1/3: … → Step 2/3: … → Synthesizing… → the answer. The user knows it's alive, and if they close the tab the next out.emit returns false and the agent stops mid-flight.

Large outputs, same primitive

The sibling of sse is stream(req, content_type), for raw chunked bodies. If your agent produces a big artifact — a generated CSV, a long report — stream it out with out.write(chunk) instead of building the whole string in memory:

def export(req)
  stream(req, "text/csv") do |out|
    out.write("step,finding\n")
    for row in agent_findings()
      out.write(row.step + "," + csv_escape(row.finding) + "\n")
    end
  end
end

One thing to keep in mind

A stream holds one worker thread for its entire lifetime. Backpressure is automatic — a slow client pauses the block — but if you expect many concurrent long-lived streams, size your worker pool accordingly, and always honor the false return from out.emit so abandoned runs free their worker promptly. For a handful of agent runs at a time, it's a non-issue, and the UX payoff is enormous.

See the Streaming & SSE reference for the full API.