Skip to content

Follow a run live

After this page you can submit a turn, watch the agent work, answer a question it asks, detect the exact moment the run reaches a terminal state, and pick the stream back up after your process restarts without losing or duplicating a single event.

Examples use $WAMP_API for the API origin your operator gives you — written as https://api.example.com throughout these guides — and $WAMP_TOKEN for a short-lived app-installation bearer, which Authentication explains how to mint.

There is one public way to follow a session:

GET /v1/sessions/{sessionId}/events?after=<sequence>&limit=<1..100>

It is an ordinary JSON request that returns a page and closes. It is not Server-Sent Events, not a long poll, and not a WebSocket — the request returns immediately with whatever has been recorded so far, including an empty page. You follow a run by calling it repeatedly with the cursor from the previous response.

{
"events": [
{
"id": "6f1c1d80-6c2a-4f0e-9d5f-0c2a2f0f4b11",
"type": "wamp.run.started",
"createdAt": "2026-08-11T09:00:01.412Z",
"sequence": 1,
"visibility": "summary",
"subject": {
"sessionId": "3f7d4f4c-2b6a-4a2e-9c1a-1f2b3c4d5e6f",
"runId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
},
"data": { "status": "running" }
}
],
"nextAfter": 1,
"hasMore": false
}

That shape is deliberate. Webhooks can push you a notification that something happened (see Receive webhooks), but the events themselves always come from this endpoint, in this order, so there is exactly one place your integration reads truth from.

Guarantee What it means for your loop
sequence is an integer starting at 1 It is the cursor. Nothing else is.
Sequences are contiguous per session You may assert sequence === previous + 1 and treat a gap as a bug, not as loss.
after is exclusive, default 0 after=0 means “from the very beginning”.
Events are append-only and immutable A replayed page is byte-identical. Reprocessing is safe if your handlers are.
Duplicates are removed before storage The runtime that produces events retries at-least-once; the log dedupes, so the same fact never appears twice.
nextAfter never regresses An empty page returns the after you sent, so an unconditional loop cannot lose its place.
visibility is always "summary" The literal summary is the only value /v1 serves.
limit is 1–100, default 100 ?limit=0, ?limit=101 and ?limit=abc are each a 400.

The log is retained for as long as the session row exists, and archiving a session is a soft delete, so a cursor is still valid after an outage of arbitrary length. If you lose the cursor entirely, replay from after=0.

Events are written at the run’s boundaries, not continuously as the model produces tokens. Concretely:

  • wamp.run.started lands once, when a worker observes the run executing in a sandbox.
  • Everything the run produced — assistant messages, tool activity, artifacts — is committed in one batch when the run reaches a terminal state or pauses to ask a question. Within that batch, the transcript comes first and the terminal wamp.run.* event is last.

That ordering is a guarantee worth designing around: by the time you see wamp.run.completed, every event belonging to that run already has a lower sequence and is already in your hands. A loop that stops at the terminal event has not missed anything.

The practical consequence is that this is a progress feed, not a token stream. If your product needs a live typing indicator, drive it from run.status and wamp.run.started; do not expect partial assistant text to trickle in.

A turn and its run are one durable pair that shares one identifier: the turnId you mint is also the runId. You therefore know the run id before the request returns.

  1. Mint a UUID for the turn.

    Terminal window
    TURN_ID=$(uuidgen | tr 'A-Z' 'a-z')
  2. PUT it. Creation is idempotent on that id — see API conventions.

    Terminal window
    curl -sS -X PUT \
    "$WAMP_API/v1/sessions/$SESSION_ID/turns/$TURN_ID" \
    -H "Authorization: Bearer $WAMP_TOKEN" \
    -H 'Content-Type: application/json' \
    -d '{"message":"Add a regression test for the date parser."}'

    The response is 202 Accepted:

    {
    "turn": { "id": "", "ordinal": 2, "dispatch": "pending", "run": { "id": "" } },
    "run": { "id": "", "status": "queued", "attempts": 0, "cancellationRequested": false },
    "created": true
    }

    202 is honest: the turn and a queued run are committed to Postgres, and nothing has been scheduled onto a sandbox yet. created: false means this was an exact replay of a turn you already submitted.

  3. Start polling events from the cursor you last persisted for this session — not from 0, unless this is the first turn.

Four event types close a run, and each carries the final status in data:

Event data.status
wamp.run.completed completed
wamp.run.failed failed
wamp.run.cancelled cancelled
wamp.run.crashed crashed

wamp.run.started (data.status: "running") is the only non-terminal run event. Match on subject.runId so a terminal event from an earlier run in the same session does not end your loop early.

Terminal run events may also carry stopReason, costUsd and turns:

{
"type": "wamp.run.completed",
"subject": { "sessionId": "", "runId": "" },
"data": { "status": "completed", "stopReason": "completed", "costUsd": 0.0123, "turns": 3 }
}

stopReason is one of completed, aborted, max_turns, max_response, error, cost_budget, turn_budget, time_budget, loop_detected.

The authoritative check, if you want one that does not depend on having observed an event, is GET /v1/sessions/{sessionId}/runs/{runId}. A run has a completedAt timestamp exactly when its status is terminal — that equivalence is enforced by a database constraint, so completedAt != null is a sound “is it done” test.

An agent can block on a human decision. When it does, the run moves to awaiting and you receive:

{
"type": "wamp.interaction.opened",
"data": {
"interactionId": "9d1f0a3c7b2e4d8f",
"kind": "ask_user",
"request": {
"questions": [
{
"question": "Which package should the test live in?",
"options": ["core", "cli"],
"multiSelect": false,
"allowCustom": true
}
]
}
}
}

interactionId is an opaque string of at most 255 characters minted by the runtime. Store it and echo it back; do not parse it or assume a format.

You answer by submitting a normal turn that names the interaction:

Terminal window
curl -sS -X PUT \
"$WAMP_API/v1/sessions/$SESSION_ID/turns/$(uuidgen | tr 'A-Z' 'a-z')" \
-H "Authorization: Bearer $WAMP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"message":"core","replyTo":{"interactionId":"9d1f0a3c7b2e4d8f"}}'

Then keep polling — the same run continues, and you will see wamp.interaction.resolved carrying resolvedByTurnId. Two failure modes are worth coding for:

  • Submitting a plain turn while an interaction is open is 409 cloud_turn_unresolved.
  • Answering an interaction that is already resolved, expired, or not the open one is 409 cloud_interaction_conflict.

An interaction can also expire on its own (wamp.interaction.expired, reason one of workspace_lost, session_archived, run_cancelled); after that it can no longer be answered.

If your process restarted and you do not know what is outstanding, GET /v1/sessions/{sessionId}/interactions returns only the open ones, oldest first. It is the actionable edge, not history — history comes from events.

This is the whole protocol, with no SDK and no dependencies. It persists the cursor through the caller, asserts the gap-free invariant, answers nothing and stops at the terminal event for one specific run.

const API = process.env.WAMP_API; // e.g. https://api.example.com — your operator gives you this
const TOKEN = process.env.WAMP_TOKEN;
const auth = { Authorization: `Bearer ${TOKEN}` };
const TERMINAL = new Set([
'wamp.run.completed',
'wamp.run.failed',
'wamp.run.cancelled',
'wamp.run.crashed',
]);
async function get(path) {
const res = await fetch(`${API}${path}`, { headers: auth });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err = new Error(`HTTP ${res.status} (${body.error ?? 'unknown'})`);
err.status = res.status;
err.code = body.error;
err.retryAfter = Number(res.headers.get('retry-after')) || undefined;
throw err;
}
return res.json();
}
/** Follow one run. `after` is the cursor you persisted for this session. */
export async function followRun(sessionId, runId, after = 0) {
let cursor = after;
let seen = cursor;
for (;;) {
const page = await get(
`/v1/sessions/${sessionId}/events?after=${cursor}&limit=100`,
);
for (const event of page.events) {
if (event.sequence !== seen + 1) {
throw new Error(`event gap: expected ${seen + 1}, got ${event.sequence}`);
}
seen = event.sequence;
handle(event); // your own dispatch
if (TERMINAL.has(event.type) && event.subject.runId === runId) {
// Persist the cursor BEFORE returning, so a crash here replays nothing.
await saveCursor(sessionId, event.sequence);
return { status: event.data.status, cursor: event.sequence };
}
}
cursor = page.nextAfter; // never regresses, safe when empty
await saveCursor(sessionId, cursor);
if (page.hasMore) continue; // page was capped — no sleep
await new Promise((r) => setTimeout(r, 1000));
}
}
function handle(event) {
switch (event.type) {
case 'wamp.message.created':
console.log(event.data.text); // a whole message, not a token
break;
case 'wamp.activity.completed':
console.log(`[${event.data.category}] ${event.data.name}${event.data.status}`);
break;
case 'wamp.interaction.opened':
console.log('needs an answer:', event.data.interactionId);
break;
case 'wamp.artifact.created':
console.log('artifact:', event.data.kind, event.data.artifactId);
break;
default:
break; // unknown types are ignored, by contract
}
}

There is no resume token, no Last-Event-ID, and no subscription state on the server. The cursor is the resume protocol:

  1. Persist nextAfter (or the sequence of the last event you fully handled) in your own storage, keyed by session.
  2. On restart, call the same endpoint with that value.
  3. If you handled an event but crashed before persisting, you will see it again. Make handlers idempotent on the event id, which is stable across replays.

Persisting after handling gives you at-least-once processing; persisting before gives you at-most-once. Choose deliberately — the server cannot make that choice for you.

Nothing in the API prescribes an interval. What constrains you is one token bucket shared by every call your installation makes, and every response tells you where you stand in it:

RateLimit-Policy: "wamp-cloud";q=600;w=60;wamp-burst=120
RateLimit: "wamp-cloud";r=117;t=59

Read r (remaining) rather than hardcoding a rate — the policy is operational flow control that an operator can tune, not an entitlement. At the shipped default, one poll per second per active session is comfortable. See Limits. When you exceed it you get 429 with Retry-After; honor it rather than tightening the loop.

A terminal run event is not the end of the work you care about.

  • Results. GET /v1/sessions/{sessionId}/artifacts lists what the run produced — a result.summary markdown document, files the agent chose to present, and a github.pull_request record if it published one. Fetch bytes from …/artifacts/{artifactId}/content.
  • Transcript. Every wamp.message.created and wamp.activity.completed event you already paged through is the transcript. It is bounded on purpose: assistant text and tool names with success or failure, never raw tool inputs, tool outputs, or model reasoning.
  • Continuation. GET /v1/sessions/{sessionId} returns a continuation object telling you whether another turn can pick up where this one stopped, and at what fidelity, if the sandbox is gone.
  • Next turn. Only one run per session may be non-terminal at a time. Submitting a turn while one is in flight is 409 cloud_run_in_progress with Retry-After: 2.