Skip to main content
Instead of polling GET /executions/{id}, watch a run live over Server-Sent Events. The stream replays the full timeline on connect, follows the execution live, and closes cleanly on the terminal event — so a detail view can render its entire timeline from the stream alone. Two endpoints stream events:
  • GET /executions/{execution_id}/stream — one execution, with per-step detail.
  • GET /jobs/{job_id}/stream — a job plus each child execution’s lifecycle (no per-step events).

Why a stream token

A browser EventSource cannot send Authorization / X-API-Key headers on the streaming GET. So the stream is gated by a short-lived (60-second) stream token passed as a ?token= query parameter, minted from POST /auth/stream-token. The token is a self-issued HS256 JWT bound to your org_id and the specific resource; it is stateless, and reusable within its 60s TTL so browser-native reconnect works.

Step 1 — Mint a stream token

POST /auth/stream-token with the resource you want to watch. Any authenticated principal (X-API-Key or Clerk JWT, any role) may mint a token for a resource it owns. Ownership is verified 404-not-403 — a missing resource and a cross-tenant resource both return 404.
curl -X POST https://api.getdialed.ai/flows/auth/stream-token \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"resource_type": "execution", "resource_id": "exec_aabbccdd"}'
import os
import httpx

res = httpx.post(
    "https://api.getdialed.ai/flows/auth/stream-token",
    headers={"X-API-Key": os.environ["API_KEY"]},
    json={"resource_type": "execution", "resource_id": "exec_aabbccdd"},
)
token = res.json()["token"]
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
FieldTypeDescription
resource_typestringexecution or job (closed set).
resource_idstringThe execution or job id to stream.
StatusCondition
401Missing or invalid auth
404Resource not found or not owned by the caller
503Stream auth not configured (server STREAM_TOKEN_SECRET unset)

Step 2 — Connect and reconnect (the client contract)

The wrapper below is the canonical browser client. It mints a token, opens an EventSource with the token as a query parameter, dedups by (entity_id, sequence), re-mints on token expiry, and — critically — calls EventSource.close() on the terminal event so the browser does not auto-reconnect forever.
Do not redesign this contract. Within the 60s TTL, browser-native EventSource auto-reconnect (which resends the last frame’s id as the Last-Event-ID header) just works. Past expiry the stream returns 401; the wrapper re-mints a fresh token and reopens. Because a fresh connection replays the full backlog, the client must dedup by (entity_id, sequence) — every frame’s id is {entity_id}#{sequence}.
/**
 * Stream one execution (or job) to completion.
 * @param {"execution"|"job"} resourceType
 * @param {string} resourceId  e.g. "exec_aabbccdd" or "job_11223344"
 * @param {() => Promise<string>} getAuthHeader  returns your real "X-API-Key: ..." value
 * @param {(event: {type: string, data: any}) => void} onEvent
 */
async function streamResource(resourceType, resourceId, getApiKey, onEvent) {
  const BASE = "https://api.getdialed.ai/flows";
  const streamPath =
    resourceType === "execution"
      ? `/executions/${resourceId}/stream`
      : `/jobs/${resourceId}/stream`;

  const seen = new Set(); // "(entity_id, sequence)" pairs already rendered
  let lastEventId = null; // "{entity_id}#{sequence}" of the last frame processed
  let es = null;
  let done = false;

  // Mint a fresh 60s stream token for this resource.
  async function mintToken() {
    const res = await fetch(`${BASE}/auth/stream-token`, {
      method: "POST",
      headers: {
        "X-API-Key": await getApiKey(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        resource_type: resourceType,
        resource_id: resourceId,
      }),
    });
    if (!res.ok) throw new Error(`stream-token mint failed: ${res.status}`);
    return (await res.json()).token;
  }

  const TERMINAL = new Set(["execution.completed", "execution.failed"]);
  const EVENT_TYPES = [
    "execution.created",
    "step.started",
    "step.completed",
    "step.failed",
    "execution.completed",
    "execution.failed",
  ];

  function handleFrame(type, evt) {
    // Frame id is "{entity_id}#{sequence}" — the Last-Event-ID resume anchor.
    lastEventId = evt.lastEventId;
    const key = evt.lastEventId; // already "(entity_id, sequence)" in one string
    if (seen.has(key)) return; // dedup cross-reconnect repeats (job stream)
    seen.add(key);

    const data = JSON.parse(evt.data);
    onEvent({ type, data });

    // Terminal event: stop. A browser EventSource otherwise reconnects forever.
    if (TERMINAL.has(type)) {
      done = true;
      es.close();
    }
  }

  async function connect() {
    if (done) return;
    const token = await mintToken();
    es = new EventSource(`${BASE}${streamPath}?token=${encodeURIComponent(token)}`);

    for (const type of EVENT_TYPES) {
      es.addEventListener(type, (evt) => handleFrame(type, evt));
    }

    es.onerror = () => {
      if (done) return;
      // readyState CLOSED = fatal (token expired -> 401). CONNECTING = the
      // browser is auto-reconnecting within the TTL (Last-Event-ID header sent
      // for us) — let it. On a fatal close, re-mint and reopen; the full
      // backlog replays and (entity_id, sequence) dedup drops the repeats.
      if (es.readyState === EventSource.CLOSED) {
        es.close();
        connect().catch((err) => onEvent({ type: "error", data: String(err) }));
      }
    };
  }

  await connect();
}

Event-shape reference

Each SSE frame carries id: {entity_id}#{sequence}, an event: type, and a JSON data: payload (entity_type, entity_id, job_id, event_type, sequence, step_name, step_index, payload, error_type, message, truncated, created_at). There are six v1 event types:
EventMeaning
execution.createdThe execution started.
step.startedA workflow step began (step_name, step_index).
step.completedA step finished; payload carries its output (size-capped — see below).
step.failedA step failed; structured error_type + human message (never a Python traceback).
execution.completedTerminal — final result in payload; the stream closes after this frame.
execution.failedTerminal — structured error; the stream closes after this frame.
The job stream (GET /jobs/{id}/stream) emits the job’s own status events plus each child execution’s execution.created / execution.completed / execution.failed — but not per-step events. For per-step detail on a fanned-out record, open that child’s own GET /executions/{child_id}/stream.
Truncation and the REST fallback. step.completed and execution.completed payloads above a size threshold are truncated with truncated: true on the event. The UI then falls back to GET /executions/{id} for the full body — but when a result exceeds the size cap, both the streamed payload and the persisted Execution.result are truncated (same size discipline). So the REST fallback returns the capped representation, not the original oversized body. The full, untruncated value is retained only in the platform’s internal execution history. Do not assume the REST fallback recovers arbitrarily large outputs.
Connecting to an already-terminal execution replays its full history ending with the terminal event, then closes — the detail view uses the same stream path regardless of execution state (no 409/410 branch). A :keep-alive comment is emitted every 30 seconds to hold the connection open through the load-balancer idle timer.

Next steps

Execution streaming

The full event taxonomy, reconnect semantics, and truncation contract.

Jobs and executions

The lifecycle and statuses the stream surfaces live.