Durable Publications
Named, durable change-feeds with server-side cursor tracking, subscriber slots, backfill on reconnect, and explicit at-least-once ACK.
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
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 | |
|---|---|---|
| Named | No | Yes |
| Agent scoping | Ad-hoc (?agents=) | Defined on the publication |
| Cursor tracking | Client-side | Server-side per slot |
| Resume on reconnect | Manual ?since= | Automatic from slot cursor |
| Multiple independent consumers | No | Yes — each slot tracks its own cursor |
| Tombstone visibility | flags field | flags 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.toolResponse:
`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}
}| Field | Description |
|---|---|
publication_id | Publication this event belongs to |
slot_id | The slot whose cursor was just advanced |
agent_id | Which agent received the write |
content_hash | Permanent BLAKE3 address of the record |
flags | 0 = normal write · 2 = TOMBSTONE (logical delete) |
cursor | New value of the slot cursor after this event |
payload | Decoded 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.toolACK 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.
---