← All lessons/Real-Time
42
Real-Time

Durable Publications

Named, durable change-feeds with server-side cursor tracking, subscriber slots, backfill on reconnect, and explicit at-least-once ACK.

Prerequisite: Lesson 41 complete

What you'll learn

  • POST /v1/publications — create a named change-feed scoped to specific agents or all agents
  • POST /v1/publications/:id/slots — create a subscriber slot with a starting cursor_hlc
  • GET /v1/publications/:id/stream?slot=<slot_id> — durable SSE: backfill then live events
  • Publication event format: publication_id, slot_id, agent_id, content_hash, flags, cursor, payload
  • Tombstone events: flags === 2 means logical delete — handle separately from writes
  • PATCH /v1/publications/:id — update name, agents, or disable (enabled: false) without deleting slots
  • POST /v1/publications/:id/slots/:slot_id/ack — explicit ACK for at-least-once semantics
  • Backfill mechanics: subscribe-before-backfill ensures no gap; 1000-record cap per agent per reconnect
  • TypeScript durable consumer pattern with auto-reconnect
Challenge

Create a publication over orders + payments. Open two terminal streams with two different slots. Write 5 records to each agent. Confirm both streams receive all 10 events independently. Disconnect one stream, write 3 more records, reconnect — confirm it backfills the 3 missed events.

What you'll learn

Named, durable change-feeds with server-side cursor tracking, subscriber slots, backfill on reconnect, and explicit at-least-once ACK. The full Publications API — not just the quick-start from Lesson 41.

Publications vs raw SSE stream

Raw SSE (/v1/stream)Publications
NamedNoYes
Agent scopingAd-hoc (?agents=)Defined on the publication
Cursor trackingClient-sideServer-side per slot
Resume on reconnectManual ?since=Automatic from slot cursor
Multiple independent consumersNoYes — each slot tracks its own cursor
Tombstone visibilityflags fieldflags field

Step 1 — Create a publication

# Cover orders and payments only
curl -s -X POST http://localhost:7475/v1/publications \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \
  -d '{"name": "order-events", "agents": ["orders", "payments"]}' \
  | python3 -m json.tool

Response: `json { "id": "a1b2c3d4", "name": "order-events", "agents": ["orders", "payments"], "enabled": true, "created_at_ms": 1754478000000, "event_count": 0, "last_event_ms": null } `

Omit agents (or pass null) to cover all agents on the instance.

Step 2 — Create a subscriber slot

Each slot has its own cursor. Two different services can consume the same publication independently.

`bash PUB_ID="a1b2c3d4"

# Consumer A — start from the beginning of time (cursor_hlc: 0) curl -s -X POST http://localhost:7475/v1/publications/$PUB_ID/slots \ -H "Content-Type: application/json" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ -d '{"name": "analytics-consumer", "cursor_hlc": 0}' \ | python3 -m json.tool

# Consumer B — start from now (only future events) NOW_HLC=$(python3 -c "import time; print(int(time.time()*1000))") curl -s -X POST http://localhost:7475/v1/publications/$PUB_ID/slots \ -H "Content-Type: application/json" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ -d "{\"name\": \"notification-consumer\", \"cursor_hlc\": $NOW_HLC}" \ | python3 -m json.tool `

Step 3 — Open the durable SSE stream

`bash PUB_ID="a1b2c3d4" SLOT_ID="slot_xyz"

curl -N "http://localhost:7475/v1/publications/$PUB_ID/stream?slot=$SLOT_ID" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" `

On connect, the server: 1. Subscribes to the global broadcast bus 2. Backfills all records from covered agents written after slot.cursor_hlc, oldest-first 3. Advances the slot cursor to the last backfilled event 4. Streams live events, advancing the cursor after each delivery

Full event format

event: record.written
data: {
  "publication_id": "a1b2c3d4",
  "slot_id":        "slot_xyz",
  "agent_id":       "orders",
  "record_id":      "018f3c2a-...",
  "content_hash":   "3a7bd3f1...",
  "flags":          0,
  "cursor":         1754480042000,
  "event_type":     "record.written",
  "payload":        {"order_id": "ord_123", "total": 4999}
}
FieldDescription
publication_idPublication this event belongs to
slot_idThe slot whose cursor was just advanced
agent_idWhich agent received the write
content_hashPermanent BLAKE3 address of the record
flags0 = normal write · 2 = TOMBSTONE (logical delete)
cursorNew value of the slot cursor after this event
payloadDecoded JSON payload (null for raw msgpack writes)

A heartbeat arrives every 30 seconds to keep the connection alive: ` event: heartbeat data: {"event": "heartbeat", "publication_id": "a1b2c3d4", "slot": "slot_xyz"} `

Handling tombstones

Tombstones (logical deletes) are delivered as events with flags === 2. Handle them separately:

es.addEventListener("record.written", (e) => {
  const event = JSON.parse(e.data);
  if (event.flags === 2) {
    // This record was soft-deleted — remove from your read model
    handleDelete(event.agent_id, event.content_hash);
  } else {
    handleWrite(event.agent_id, event.payload, event.cursor);
  }
});

Update a publication

`bash # Disable a publication (stops stream delivery; slots and cursors are preserved) curl -s -X PATCH http://localhost:7475/v1/publications/$PUB_ID \ -H "Content-Type: application/json" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ -d '{"enabled": false}' \ | python3 -m json.tool

# Change the covered agents curl -s -X PATCH http://localhost:7475/v1/publications/$PUB_ID \ -H "Content-Type: application/json" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ -d '{"agents": ["orders", "payments", "refunds"]}' \ | python3 -m json.tool

# Switch to all-agents mode curl -s -X PATCH http://localhost:7475/v1/publications/$PUB_ID \ -H "Content-Type: application/json" \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ -d '{"agents": null}' \ | python3 -m json.tool `

A disabled publication returns 409 Conflict on stream connect.

Manage slots

`bash # List all slots for a publication curl -s http://localhost:7475/v1/publications/$PUB_ID/slots \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ | python3 -m json.tool

# Inspect a slot's current cursor curl -s http://localhost:7475/v1/publications/$PUB_ID/slots/$SLOT_ID \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \ | python3 -m json.tool

# Delete a slot (stops tracking — does not affect other slots) curl -s -X DELETE http://localhost:7475/v1/publications/$PUB_ID/slots/$SLOT_ID \ -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" `

Explicit ACK — at-least-once semantics

By default, the SSE stream advances the slot cursor on delivery (at-most-once). For at-least-once: process records yourself, then explicitly ACK.

# After successfully processing records up to this HLC cursor
curl -s -X POST http://localhost:7475/v1/publications/$PUB_ID/slots/$SLOT_ID/ack \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY" \
  -d '{"cursor": 1754480042000}' \
  | python3 -m json.tool

ACK only advances the cursor — it never moves it backwards.

Delete a publication

curl -s -X DELETE http://localhost:7475/v1/publications/$PUB_ID \
  -H "Authorization: Bearer spx_root_YOUR_ROOT_KEY"

Returns 204 No Content. Cascades: all subscriber slots for this publication are deleted.

TypeScript durable consumer pattern

`typescript const PUB_ID = "a1b2c3d4"; const SLOT_ID = "slot_xyz";

function openStream() { const es = new EventSource( http://localhost:7475/v1/publications/${PUB_ID}/stream?slot=${SLOT_ID}, { headers: { Authorization: Bearer ${process.env.SAPIX_ROOT_KEY} } } );

es.addEventListener("record.written", (e) => { const event = JSON.parse(e.data); if (event.flags === 2) { handleDelete(event.agent_id, event.content_hash); } else { handleWrite(event.agent_id, event.payload); } // cursor advances server-side — no action needed here });

es.addEventListener("error", () => { es.close(); setTimeout(openStream, 2000); // server resumes from slot cursor — no events missed }); }

openStream(); `

Backfill mechanics and limits

On reconnect, the server backfills records written after slot.cursor_hlc using a time-range scan on each covered agent's strand. This uses a subscribe-before-backfill ordering: the broadcast bus subscription is opened first, so no live event is missed during backfill.

Backfill cap: 1,000 records per agent per reconnect. If a slot falls more than 1,000 records behind per agent, page through the strand manually using GET /v1/agents/:id/records?after_hlc=<cursor> before opening the stream.

Broadcast bus capacity: 1,024 events in-flight. A very slow consumer that cannot keep up will miss live events. The slot cursor only advances for events actually delivered — reconnect backfills the gap.

Challenge

Create a publication over two agents. Open two terminal streams with two different slots. Write 5 records to each agent. Confirm both streams receive all 10 events independently.

Disconnect one stream. Write 3 more records. Reconnect — confirm the reconnected stream backfills exactly the 3 missed events before continuing live.

---

← Previous
Lesson 41: Global Broadcast Bus & Publications
Next →
Lesson 43: Epigenetic Profiles