Real-timeReal-time Streams

Real-time Streams

causet-realtime is Causet’s live delivery service. After the runtime commits an intent, patches and projection writes are published to Kafka; causet-realtime consumes those topics and fans events out to connected clients over WebSocket or Server-Sent Events (SSE).

It does not execute intents or own entity state. It is a read path for live updates — complementary to named queries (poll/HTTP) and entity state (one-shot fetch).

⚠️

Subscribe only. causet-realtime never accepts intents. Submit intents with emit() against the runtime HTTP API (POST .../intents/submit). Webhook handlers verify the payload, then call emit() — the webhook is not the transport. Do not use emitStream() (runtime intent-progress SSE) in webhook handlers or short-lived request paths; it holds the connection open until execution finishes.


Where it fits

Causet separates writes from live reads:

PathServicePort (local)What it does
Writecauset-runtime-service8085Accepts intents, commits events, updates entity snapshots
Read (query)causet-query-service8086Runs named projection queries against materialized tables
Read (live)causet-realtime8081Streams ledger patches and projection writes to subscribers
Intent submit ──▶ causet-runtime-service

                         ├──▶ Kafka: causet.patches.v1
                         │         │
                         │         ▼
                         │    causet-realtime ──▶ WebSocket / SSE clients

                         ├──▶ causet-projection-worker ──▶ projection tables
                         │                                      │
                         │                                      ▼
                         └──▶ causet-query-service ◀── HTTP query API

Kafka topics consumed by causet-realtime:

  • causet.patches.v1 — ledger patches (state mutations, domain events)
  • causet.projection-writes.v1 — materialized projection row updates

Base URLs

The realtime host is separate from the SaaS API host. SDKs derive it automatically from apiUrl:

EnvironmentAPI URLRealtime HTTPWebSocket
Sandboxhttps://sandbox.api.causet.cloudhttps://sandbox.realtime.causet.cloudwss://sandbox.realtime.causet.cloud/ws
Prodhttps://api.causet.cloudhttps://realtime.causet.cloudwss://realtime.causet.cloud/ws
Localhttp://localhost:8085http://localhost:8081ws://localhost:8081/ws

Local stack: docker compose up -d causet-realtime from the Causet repo.


Authentication

Clients authenticate with a short-lived JWT minted by causet-saas-cloud:

curl -s -X POST https://sandbox.api.causet.cloud/v1/token \
  -H "Authorization: Bearer ck_live_..." | jq -r .token

The API key must include the WEBSOCKET_READ scope (or legacy STREAM_SUBSCRIBE).

SDKs call /v1/token automatically in init() and refresh the JWT before expiry. Pass the token to causet-realtime via:

  • WebSocket: Authorization: Bearer <jwt> header or ?token=<jwt> query param
  • SSE: Authorization: Bearer <jwt> header or ?token=<jwt> query param

platform_id and application_id are derived from the JWT — do not send them in subscribe messages.


Subscription modes

Subscriptions are scoped by stream, fork, and optionally entity:

Modestream_idfork_idReceives
Stream + forksku_streamsandboxAll entities on that stream/fork
Stream + fork + entitysku_stream:sku-1sandboxOnly sku-1 on that fork

Use from_cursor to replay missed events from the runtime ledger, then continue with live events. Cursor 0 starts from the beginning; pass last cursor + 1 to resume.


WebSocket

Endpoint: {realtimeBase}/ws (default path /ws, configurable via WS_PATH)

Connection flow

  1. Client opens WebSocket with JWT
  2. Client sends hello within 10s (SDK sends this automatically on connectStream)
  3. Server sends welcome
  4. Server streams replay events (if from_cursor set), then live Kafka events
  5. Client may send sub, unsub, or ping

Client → server: hello

Sent automatically by SDKs on connect:

{
  "type": "hello",
  "v": 1,
  "stream_id": "sku_stream",
  "fork_id": "sandbox",
  "subs": [
    { "channel": "ledger", "from_cursor": 0 },
    { "channel": "state", "from_cursor": 0 }
  ]
}
FieldDescription
stream_idStream name, or streamType:entityId for a single entity
fork_idFork to listen on (default main)
subsOptional channel subscriptions (ledger, state) with optional from_cursor

Server → client: welcome

{
  "type": "welcome",
  "v": 1,
  "conn_id": "550e8400-e29b-41d4-a716-446655440000",
  "server_ts": 1709068800000,
  "shard": 0
}

Server → client: ledger patch

Events are forwarded verbatim from Kafka — no type: "event" wrapper. Ledger events have cursor, stream_id, and platform_id:

{
  "cursor": 42,
  "platform_id": "org_xxx",
  "application_id": "app_xxx",
  "stream_id": "orders",
  "entity_id": "order-123",
  "fork_id": "sandbox",
  "intent_id": "intent_xxx",
  "event_type": "ORDER_PLACED",
  "patch": [
    { "op": "replace", "path": "/status", "value": "placed" },
    { "op": "add", "path": "/items/-", "value": { "sku": "prod-1", "qty": 2 } }
  ],
  "emits": [
    { "event_type": "ORDER_PLACED", "payload": { "amount": 99.99 } }
  ],
  "metadata": {
    "causation_id": "intent_xxx",
    "correlation_id": "uuid-...",
    "ruleset_version": 1,
    "deployed_version": 1,
    "ts": "2025-02-27T12:00:00Z"
  }
}
  • patch: JSON Patch RFC 6902 operations (replace, add, remove)
  • emits: Domain events emitted by rules
  • event_type: Business event (e.g. ORDER_PLACED, SET, EMIT)

Server → client: projection write

{
  "stream_id": "orders",
  "projection_name": "inventory",
  "entity_id": "order-123",
  "platform_id": "org_xxx",
  "application_id": "app_xxx",
  "fork_id": "main",
  "cursor": 42,
  "event_type": "projection:inventory",
  "trigger_event_type": "ORDER_PLACED",
  "row": {
    "product_id": "prod-1",
    "quantity": 5,
    "updated_at": "2025-02-27T12:00:00Z"
  },
  "ts": "2025-02-27T12:00:00Z"
}
  • row: The materialized projection row
  • trigger_event_type: The ledger event that caused this update
  • event_type: projection:{projectionName} for filtering

Server → client: error

{
  "type": "error",
  "code": "FORBIDDEN",
  "message": "stream access denied"
}
CodeWhen
UNAUTHORIZEDNo token, invalid JWT, or JWT expired
FORBIDDENWrong project/env, stream or channel access denied
BAD_REQUESTInvalid hello, invalid stream_id format, too many subs
NOT_FOUNDStream not found (subscribe with from_cursor)
RATE_LIMITEDToo many inbound messages
INTERNALInternal error, subscription failed

Distinguishing message types

  • Protocol messages have top-level "type" in welcome, error, redirect, pong
  • Ledger events have cursor, stream_id, platform_id — no protocol type
  • Projection events have projection_name, row, and event_type starting with projection:

SSE (Server-Sent Events)

Endpoint:

GET {realtimeBase}/v1/platforms/{platformId}/applications/{applicationId}/streams/{streamId}/events
    ?fork_id={fork}&from_cursor={cursor}&token={jwt}
ParamDescription
streamIdStream name or streamType:entityId
fork_idFork to subscribe to (default main)
from_cursorReplay from this cursor; omit or 0 for from start
tokenJWT (alternative to Authorization header)

Headers: Accept: text/event-stream, Authorization: Bearer <jwt>

Wire format

Each event:

id: 42
event: STOCK_ADJUSTED
data: {"cursor":42,"stream_id":"sku_stream","entity_id":"sku-1","fork_id":"sandbox","event_type":"STOCK_ADJUSTED","patch":[{"op":"replace","path":"/quantity","value":95}]}
  • id: Cursor for ordering and resume
  • event: Event type string (e.g. ORDER_PLACED, projection:inventory)
  • data: Raw JSON payload — same shape as WebSocket ledger/projection events

Heartbeat: the server sends : ping comments every ~15s to keep the connection alive.

When to use which transport

TransportBest for
websocketDuplex subscriptions, ledger + state channel replay, lowest latency
sseOne-way fanout, browser EventSource, mobile, HTTP-only environments

SDK usage

Every SDK exposes connectStream() (or connect_stream()) with the same subscription model. Initialize the client first so the JWT is cached.

TypeScript / JavaScript

import { CausetClient } from '@causet/sdk';
 
const client = new CausetClient({
  apiUrl: 'https://sandbox.api.causet.cloud',
  platformSlug: 'my-platform',
  appSlug: 'my-app',
  forkId: 'sandbox',
  apiKey: process.env.CAUSET_API_KEY,
});
 
await client.init();
 
// WebSocket — stream + fork (all entities)
const connId = await client.connectStream('sku_stream', {
  fromCursor: 0,
  channels: [{ channel: 'ledger' }, { channel: 'state' }],
});
 
client.on('stream_event', (ev) => {
  console.log(ev.event_type, ev.entity_id, ev.cursor);
});
 
client.on('stream_connected', ({ connId, transport }) => {
  console.log(`Connected via ${transport}: ${connId}`);
});
 
// SSE — stream + fork + entity, resume from cursor 100
await client.connectStream('sku_stream:sku-1', {
  transport: 'sse',
  fromCursor: 100,
});
 
client.disconnectStream();

Next.js hooks: useCausetEntity(stream, entityId, true) connects via WebSocket and applies live patches. See Add Causet to Next.js.

Python

from causet_sdk import CausetClient
 
client = CausetClient(
    api_url="https://sandbox.api.causet.cloud",
    platform_slug="my-platform",
    app_slug="my-app",
    fork_id="sandbox",
    api_key="ck_live_...",
)
 
await client.init()
 
conn_id = await client.connect_stream(
    "sku_stream",
    from_cursor=0,
    channels=[{"channel": "ledger"}, {"channel": "state"}],
)
 
client.on("stream_event", lambda ev: print(ev.get("event_type"), ev.get("cursor")))
 
await client.connect_stream(
    "sku_stream:sku-1",
    transport="sse",
    from_cursor=100,
)
 
client.disconnect_stream()

Go

client, _ := causet.NewClient(causet.Config{
    APIURL:       "https://sandbox.api.causet.cloud",
    PlatformSlug: "my-platform",
    AppSlug:      "my-app",
    ForkID:       "sandbox",
    APIKey:       os.Getenv("CAUSET_API_KEY"),
})
client.Init(ctx)
 
connID, _ := client.ConnectStream(ctx, "sku_stream", causet.StreamConnectOptions{
    FromCursor: 0,
    Channels:   []causet.StreamChannel{{Channel: "ledger"}, {Channel: "state"}},
}, func(ev map[string]any) {
    fmt.Println(ev["event_type"], ev["entity_id"], ev["cursor"])
})
 
connID, _ = client.ConnectStream(ctx, "sku_stream:sku-1", causet.StreamConnectOptions{
    Transport:  causet.StreamTransportSSE,
    FromCursor: 100,
}, onEvent)

Java

CausetClient client = new CausetClient(
    "https://sandbox.api.causet.cloud",
    "my-platform", "my-app", "sandbox", "ck_live_...");
client.init();
 
client.connectStream("sku_stream", null, event -> {
    System.out.println(event.get("event_type") + " " + event.get("entity_id"));
}).join();
 
CausetClient.StreamConnectOptions opts = new CausetClient.StreamConnectOptions();
opts.transport = StreamTransport.SSE;
opts.fromCursor = 100;
client.connectStream("sku_stream:sku-1", opts, event -> {
    System.out.println(event.get("cursor"));
}).join();
 
client.disconnectStream();

PHP / Laravel

connectStream() is SSE-only (Laravel/Guzzle is synchronous — no wrapped WebSocket client). It blocks the calling process for the connection’s lifetime; run it from a queue job or artisan command, not a web request handler:

use Causet\Laravel\Facades\Causet;
 
Causet::init();
 
Causet::on('stream_event', function (array $ev): void {
    logger()->info($ev['event_type'] ?? 'event', $ev);
});
 
Causet::connectStream('sku_stream:sku-1', function (array $ev): void {
    logger()->info($ev['event_type'] ?? 'event', $ev);
}, forkId: 'sandbox', fromCursor: 100);

Stop the loop from a signal handler in long-running commands:

pcntl_async_signals(true);
pcntl_signal(SIGTERM, fn () => Causet::disconnectStream());
 
Causet::connectStream('sku_stream', $onEvent);

For intent submission, use emit() from controllers, jobs, and webhook handlers. Intent-progress SSE (emitStream) hits the runtime stream endpoint — not causet-realtime — and is only for long-running server contexts where you can hold the connection open (not webhooks or typical SSR/page renders).


SDK event handlers

EventWhen
stream_connectedWebSocket or SSE connection established
stream_eventLedger patch or projection write received
patch_opIndividual JSON Patch operation from a ledger event

Call disconnectStream() / disconnect_stream() when done.


Health and operations

EndpointPurpose
GET /healthzLiveness
GET /readyzReadiness (checks Kafka)
GET /metricsPrometheus metrics