Guides

Real-time event stream

Subscribe to new on-chain events over Server-Sent Events instead of polling, and relay them to a Next.js browser client.

Overview#

GET /events/stream is a Server-Sent Events (text/event-stream) endpoint that pushes newly indexed events to subscribers in real time. Each message carries the fully decoded event as JSON, so clients don't need a follow-up fetch.

  • Auth: requires an API Key with the events:read scope.
  • Filtering: pass ?kinds= with a comma-separated list of event kind names (the trailing Event suffix is optional), e.g. ?kinds=WalkAdvancedEvent,DAGCreatedEvent. Omit to receive every kind. Every name must be one the server can decode; an unknown kind — a typo, or one a protocol release removed — is rejected with 400 INVALID_FILTER_VALUE rather than opening a stream that never delivers.
  • Resuming: each message's id is the monotonic events.id. On reconnect, the browser's EventSource automatically sends the last id back as the Last-Event-ID header and the server replays everything newer straight from the durable event log — no gaps. Non-browser clients can pass ?last_event_id= instead.

Each frame looks like:

id: 4217
event: message
data: {"id":4217,"kind":"DAGCreatedEvent","tx_digest":"...","sender_address":"0x...","payload":{...}}

Starting from history: GET /events#

A stream can only tell you what happens next. A feed also has to render what already happened, and the stream cannot answer that: it replays forward from an id, and its frames carry no timestamp, so "the newest fifty events, newest first" would mean draining the entire log to reach its tail.

GET /events is the durable half. Same decoded events, ordered newest-first, with the checkpoint timestamp (onchain_at) each frame lacks, and the usual page_token / page_size envelope. It takes the same kinds filter — validated against the same catalog, so a typo is a 400 here too — plus sender, tx_digest, and created_after / created_before against onchain_at.

The two compose into a feed with no gap and no overlap: read the first page, remember the newest id you received, and open the stream at that cursor.

const page = await fetch(`${base}/events?page_size=50`, { headers }).then((r) => r.json());
const cursor = page.items[0]?.id; // newest returned id
const stream = new EventSource(`/api/events?last_event_id=${cursor ?? ""}`);

Two properties worth knowing before you count on the numbers:

  • total_items counts rows, not renderable events. Only kinds the pinned SDK can decode are listed or counted — the indexer stores every event matching the wrapper type, which is strictly more than the catalog models. But a row that is in the catalog and still fails to decode can only be discovered by decoding it, so it is counted and then skipped: a page can come back an item or two short of page_size.
  • next_token advances by rows scanned, not items returned. That is what keeps such a skip from being re-served forever, and it is why paging to the end can yield slightly fewer items than total_items promised.

Why a server-side relay#

The browser EventSource API cannot send custom headers, so it cannot attach x-api-key directly. Keep the API Key on the server: have a Next.js Route Handler authenticate to the Talus API and relay the stream to the browser. The key never reaches the client.

From Node (or any environment with fetch), prefer the typed client instead — client.events.stream() sends x-api-key natively. See TypeScript client.

sequenceDiagram
    participant Browser as Browser (EventSource)
    participant Next as Next.js Route Handler
    participant API as Talus API (/events/stream)

    Browser->>Next: GET /api/events?kinds=DAGCreatedEvent
    Next->>API: GET /events/stream?kinds=... (x-api-key)
    API-->>Next: text/event-stream
    Next-->>Browser: text/event-stream (relayed)

Next.js Route Handler (server-side relay)#

// app/api/events/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(request: Request) {
  const url = new URL(request.url);
  const kinds = url.searchParams.get("kinds") ?? "";

  const upstream = new URL(`${process.env.TALUS_API_URL}/events/stream`);
  if (kinds) upstream.searchParams.set("kinds", kinds);

  // Forward the browser's resume cursor so reconnects don't drop events.
  const lastEventId = request.headers.get("last-event-id");

  const res = await fetch(upstream, {
    headers: {
      "x-api-key": process.env.TALUS_API_KEY!, // stays on the server
      accept: "text/event-stream",
      ...(lastEventId ? { "last-event-id": lastEventId } : {}),
    },
    // Keep the connection open and stream the body straight through.
    signal: request.signal,
  });

  if (!res.ok || !res.body) {
    return new Response("upstream error", { status: 502 });
  }

  return new Response(res.body, {
    status: 200,
    headers: {
      "content-type": "text/event-stream",
      "cache-control": "no-cache, no-transform",
      connection: "keep-alive",
    },
  });
}

Browser client (EventSource)#

"use client";

export function useEventStream(kinds: string[]) {
  useEffect(() => {
    const params = kinds.length ? `?kinds=${kinds.join(",")}` : "";
    const source = new EventSource(`/api/events${params}`);

    source.onmessage = (event) => {
      const payload = JSON.parse(event.data);
      // payload.kind, payload.payload, payload.id, ...
      console.log("event", payload.kind, payload);
    };

    // EventSource reconnects automatically and replays from Last-Event-ID.
    source.onerror = () => {
      // The browser will retry; nothing to do unless you want custom backoff.
    };

    return () => source.close();
  }, [kinds]);
}

Event kind renames in v2.0.0-rc.5#

?kinds= filters on the on-chain event name, so the rc.5 protocol upgrade changes the values you pass. Update any hard-coded filter:

Before (rc.4) After (rc.5)
ScheduledSkillExecutionCreatedEvent TaskCreatedEvent
ScheduledSkillExecutionPausedEvent TaskPausedEvent
ScheduledSkillExecutionResumedEvent TaskResumedEvent
ScheduledSkillExecutionCanceledEvent TaskCanceledEvent
RequestScheduledOccurrenceEvent OccurrenceScheduledEvent
OccurrenceConsumedEvent OccurrenceDispatchedEvent
MissedOccurrenceEvent OccurrenceMissedEvent
VerificationVerdictEvent ToolVerificationResolvedEvent
PaymentInsufficientGasEvent ToolPaymentInsufficientFundsEvent (removed again in rc.final — see below)
PaymentLockUpdateEvent ToolPaymentLockUpdatedEvent (removed again in rc.final — see below)
PaymentUnlockUpdateEvent ToolPaymentSettledEvent (removed again in rc.final — see below)
ScheduledOccurrencePaymentCreatedEvent TaskExecutionPaymentCreatedEvent
ScheduledOccurrencePaymentFinalizedEvent TaskExecutionPaymentFinalizedEvent
ScheduledSkillPaymentRefilledEvent TaskPaymentReserveRefilledEvent
ScheduledSkillPaymentCanceledEvent TaskPaymentReserveCanceledEvent

Removed with no successor — a ?kinds= filter naming any of them is rejected whole with 400 INVALID_FILTER_VALUE (the filter is validated as a set, so one stale name fails the entire subscription): ExecutionPaymentReceiptCreatedEvent, ExecutionPaymentReceiptResolvedEvent, ScheduledPaymentReserveReceiptCreatedEvent, PeriodicScheduleConfiguredEvent.

Several payloads also changed shape. The most visible: RequestWalkExecutionEvent replaced the optional scheduled_task_id / scheduled_occurrence_index with plain task_id / occurrence_id, because rc.5 routes every execution through the scheduler.

Net-new rc.5 event kinds#

These kinds have no rc.4 predecessor and are projected as of this pass — filter on them with ?kinds= the same as any other kind, and see the corresponding new API surface for the projected data: OccurrenceAdvertisedEvent, OccurrenceWithdrawnEvent, OccurrenceSettledEvent, TaskClosedEvent, WalkPendingAbortEvent, SubmissionFailureEvidenceRecordedEvent (GET /executions/{id}/submission-failures), ExecutionPaymentRefilledEvent, ExecutionPaymentInsufficientSettlementEvent, ExecutionPaymentFeesRecordedEvent, ExecutionPaymentToolCostSnapshottedEvent (all four on GET /executions/{id}/payment-ledger), PriorityFeeSwapEvent, PriorityFeeDepositCreatedEvent (GET /priority-fee-vault/events).

Other rc.5 API changes#

  • GET /tasks now filters by controller (matching either an owning address or an owning agent); the rc.4 owner query parameter is no longer recognized and is silently ignored like any unknown parameter — update integrations or they will receive the unfiltered task list.
  • The rc.5 deployment resets the event log, so stored Last-Event-ID values from rc.4 are invalid. The stream clamps an out-of-range cursor to the current tip and resumes live rather than replaying.

v2.0.0-rc.7: no renames, many new kinds#

Unlike the rc.5 upgrade, rc.7 renames nothing and removes nothing: every kind you already filter on keeps its name, and no event payload changed shape. Existing ?kinds= filters keep working as-is.

What changed is coverage. The SDK now derives its event catalog from the protocol packages themselves rather than a hand-maintained list, so about thirty kinds became decodable at once. Only two of them were genuinely new on chain (PriorityFeeDepositCreatedEvent, PriorityFeeSharesCollectedEvent); the rest were always emitted, just not decodable before.

Of those, only ToolUpdatedEvent is projected into an endpoint — rc.7 lets a registered tool change its off-chain URL, description and schema in place, and GET /tools now reflects that instead of serving registration-time values. The rest are streamable but not yet projected: the registry::leader staking family, the network_auth key family, the granular DAG* construction events, ToolSlashedEvent, ExternalVerifierRegisteredEvent, ToolVerificationStatusChangedEvent, and the priority-fee vault events. Subscribe to them on the stream if you need them before they reach an endpoint. (The DAG* and registry::leader families both reached endpoints in rc.final — see below.)

The legacy PriorityFeeDepositEvent was removed after rc.7. GET /priority-fee-vault/events now projects PriorityFeeDepositCreatedEvent, so it shows every deposit again.

Breaking response changes in v2.0.0-rc.7#

Two response shapes changed because the on-chain data behind them changed.

Port data (ports_data on walks and walk events). rc.7 replaced the untyped inline/Walrus envelope with typed values, so a port now reports its cardinality as the key and carries a list of self-describing values. Object references are a new third kind — previously impossible to express:

// before (rc.5)
{ "output": { "storage_kind": "inline", "data": { "value": 7 } } }

// after (rc.7)
{ "output": { "one": { "kind": "data", "data": { "value": 7 } } } }

A port is either {"one": value} or {"many": [value, ...]}. Each value carries its own kind: data (inline payload), object (a Sui object id), or walrus (blob_id and content_digest, both base64 — the API never resolves Walrus content). Because the kind is per value, one port can mix inline payloads with Walrus references.

Two caveats on data. The chain commits inline payloads without validating them, so data is decoded JSON only when the tool actually wrote JSON — a tool that commits a raw unquoted string yields that string verbatim (for example "data": "0xgmike:2026-W35"). And a port whose bytes are not valid UTF-8 cannot be rendered at all: that port is omitted from ports_data (the map is null when no port survives), counted by the server's content-error metric.

Tool schemas (GET /tools/{fqn}). rc.7 moved JSON Schema off chain; a registered tool now carries only an immutable port contract. The input_schema and output_variants field names are unchanged, but their contents are not — they are no longer JSON Schema documents:

{
  "input_schema": [{ "port_name": "prompt", "cardinality": "one", "value_kind": "data" }],
  "output_schema": [
    {
      "variant_name": "ok",
      "ports": [{ "port_name": "result", "cardinality": "one", "value_kind": "data" }]
    }
  ]
}

value_kind is object or data. A tool whose on-chain object carries no port contract is absent from GET /tools entirely.

v2.0.0-rc.final#

These changes landed in v2.0.0-rc.final, the release this API is pinned to. The event catalog itself did not change in this release — no kind was added, removed, or renamed relative to the interim build, so no ?kinds= filter needs touching. What follows is the difference from rc.7.

rc.final is a fresh deployment on a new network, so the event log restarts from zero and a Last-Event-ID stored against an earlier release is invalid. The stream clamps an out-of-range cursor to the current tip and resumes live rather than replaying.

Renamed. A Walrus port value reports blob_id rather than storage_key, and content_digest is always present rather than nullable:

{ "output": { "one": { "kind": "walrus", "blob_id": "...", "content_digest": "..." } } }

Removed. The whole tool-payment and per-vertex execution-payment event family is gone: ToolPaymentInsufficientFundsEvent, ToolPaymentLockUpdatedEvent, ToolPaymentSettledEvent, ExecutionPaymentVertexLockedEvent, and ExecutionPaymentVertexSettledEvent. A ?kinds= filter naming any of them is rejected with 400 INVALID_FILTER_VALUE, which fails the whole subscription. GET /executions/{execution_object_id}/gas has no writer as a result, and the payment ledger no longer records vertex_locked or vertex_settled rows.

SkillRecord lost scheduled_task_count, so the field is gone from the skill objects in GET /agents.

New, not yet projected. Payments were replaced by an invocation model: InvocationLockedEvent, InvocationSettledEvent, InvocationAuthorizationRequiredEvent, CreditsCreatedEvent, CreditOfferChangedEvent, TimePassCreatedEvent, TimePassOfferChangedEvent, PolicyAddedEvent, PolicyRemovedEvent, and CashierDepositCreatedEvent. They are streamable now; endpoints follow.

Newly projected. The six granular DAG construction events — DAGVertexAddedEvent, DAGEdgeAddedEvent, DAGEntryVertexInputPortAddedEvent, DAGDefaultValueAddedEvent, DAGOutputAddedEvent and DAGFinalizedEvent — now reach an endpoint. GET /dags/{dag_object_id} gains finalized_at (null while the DAG is still being built), GET /dags accepts a finalized filter, and a new GET /dags/{dag_object_id}/structure returns the whole topology:

{
  "object_id": "0x…",
  "finalized_at": "2026-08-27T10:00:00+00:00",
  "vertices": [{ "name": "fetch", "kind": "off_chain", "tool_fqn": "xyz.taluslabs.http@1" }],
  "edges": [
    {
      "from": { "vertex": "fetch", "variant": "ok", "port": "body" },
      "to": { "vertex": "parse", "port": "text" },
      "kind": "for_each"
    }
  ],
  "entry_groups": [{ "name": "default", "ports": [{ "vertex": "fetch", "port": "url" }] }],
  "default_values": [
    { "vertex": "fetch", "port": "retries", "value": { "one": { "kind": "data", "data": 3 } } }
  ],
  "output_variants": [{ "vertex": "fetch", "variant": "ok", "port": "body" }]
}

kind on a vertex is on_chain or off_chain; on an edge it is normal, for_each, collect, do_while, break or static. A default value uses the same self-describing shape as ports_data on walks. A DAG whose finalized_at is null may still be missing elements, so treat its structure as a snapshot of a build in progress rather than the finished graph.

The eleven registry::leader events are projected too. GET /leaders and GET /leaders/{leader_cap_id} gain status (active, suspended, slashed — null until the chain names one) alongside stake_pool_total and stake_total_shares, and GET /leaders accepts a status filter. Three endpoints are new:

  • GET /leaders/{leader_cap_id}/stake-events — the pool ledger, newest first, filterable by kind (deposited, unstake_requested, unstake_claimed, slashed) and staker. Columns outside a row's kind are null; a deposited row's total_shares and pool_total are the pool's state after the deposit, so it doubles as a checkpoint.
  • GET /leaders/{leader_cap_id}/suspension-skips — shutdown suspensions refused because the presented claim token did not own the active state, the expected race during a rolling restart. The on-chain call is a deliberate no-op, so this event is its only trace.
  • GET /registry/leader — current staking policy: min_stake_us, max_transaction_budget, unbonding_duration_ms. Each is null until the corresponding *Updated event has been seen.

stake_pool_total and stake_total_shares are running aggregates over the ledger, not a read of on-chain state. Only a deposit carries post-state; claims and slashes carry deltas. If the API started indexing after a leader's first deposit, its totals are low — never negative. Use stake-events when you need the authoritative history.

Changed shape. RequestWalkExecutionEvent carries a new invocation object id. OccurrenceAdvertisedEvent reports effective_start_time_ms and no longer carries source — it now only refreshes the start time of an occurrence OccurrenceScheduledEvent created. A task can also reach a new rejected status, distinct from cancelled.

Notes#

  • Set TALUS_API_URL and TALUS_API_KEY as server-side environment variables (never NEXT_PUBLIC_*).
  • The server sends periodic keep-alive comments so idle connections stay open through proxies.
  • Filtering and resumption are both handled by the Talus API; the relay only needs to forward the kinds query and the Last-Event-ID header.