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:readscope. - Filtering: pass
?kinds=with a comma-separated list of event kind names (the trailingEventsuffix 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 with400 INVALID_FILTER_VALUErather than opening a stream that never delivers. - Resuming: each message's
idis the monotonicevents.id. On reconnect, the browser'sEventSourceautomatically sends the last id back as theLast-Event-IDheader 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_itemscounts 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 ofpage_size.next_tokenadvances 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 thantotal_itemspromised.
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 /tasksnow filters bycontroller(matching either an owning address or an owning agent); the rc.4ownerquery 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-IDvalues 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 bykind(deposited,unstake_requested,unstake_claimed,slashed) andstaker. Columns outside a row's kind are null; adepositedrow'stotal_sharesandpool_totalare 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*Updatedevent 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_URLandTALUS_API_KEYas server-side environment variables (neverNEXT_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
kindsquery and theLast-Event-IDheader.