Publications
Subscribe to agent writes as a named, durable change feed. Each consumer gets its own slot with a server-tracked cursor — reconnects resume automatically from where the consumer left off.
Publications vs. raw SSE stream
| Raw SSE 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 — one slot per consumer |
| Tombstone visibility | flags field | flags field |
Quick start
Three steps: create a publication, create a slot, open the stream.
POST /v1/publications
Authorization: Bearer <key>
Content-Type: application/json
{
"name": "order-events",
"agents": ["orders", "payments"]
}
// Response 201
{
"id": "a1b2c3d4",
"name": "order-events",
"agents": ["orders", "payments"],
"enabled": true,
"created_at_ms": 1754478000000,
"event_count": 0,
"last_event_ms": null
}POST /v1/publications/a1b2c3d4/slots
Authorization: Bearer <key>
Content-Type: application/json
{
"name": "analytics-consumer",
"cursor_hlc": 0
}
// Response 201
{
"id": "slot_xyz",
"publication_id": "a1b2c3d4",
"name": "analytics-consumer",
"cursor_hlc": 0,
"created_at_ms": 1754478060000,
"last_active_ms": null
}GET /v1/publications/a1b2c3d4/stream?slot=slot_xyz
Authorization: Bearer <key>
// SSE events arrive as records are written to orders or payments:
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 }
}Event format
| Field | Type | Description |
|---|---|---|
publication_id | string | Publication this event belongs to |
slot_id | string | The consuming slot whose cursor was just advanced |
agent_id | string | Agent that received the write |
record_id | string | UUID of the written record |
content_hash | string | SHA-256 content hash of the record payload |
flags | number | 0 = normal write · 2 = TOMBSTONE (logical delete) |
cursor | number | HLC timestamp (ms) — new cursor value after this event |
payload | object | null | Decoded JSON payload; null for raw msgpack writes |
flags === 2, the record is a logical deletion marker. The original record is not removed from the immutable strand — a TOMBSTONE is appended after it. Consumers should treat these as delete events and remove the item from their downstream store.Row-policy filtering
If the caller presents a JWT, each event's row policy — resolved against the event's own agent_id, since a publication can cover many agents at once — is applied before the event is delivered, both during backfill and live. A caller with no JWT (root or a scoped service key) sees every event, unaffected.
Authentication and Scopes
Creating, listing, updating, and deleting publications and subscriber slots all require the root key or a scoped key holding admin:publications — a publication can cover every agent on the instance ("agents": null), so this is treated as cluster-wide configuration, not a per-tenant write. GET /v1/publications/:id/stream itself only needs read:publications.
That stream is additionally checked per event: for an agent with no row policy configured (row policies are opt-in), a subscriber with only read:publications and no relationship to that specific agent would otherwise see its records anyway. Each event is now also checked against read:agents/<event's agent_id> and dropped (not the connection rejected) if the subscriber lacks it — the same drop-not-null pattern the row filter above already uses.
TypeScript — durable consumer
Create the publication and slot once. On every subsequent deploy or restart, just open the stream — SapixDB resumes from the last confirmed cursor automatically.
const BASE = "https://your-instance.sapixdb.com";
const KEY = process.env.SAPIX_API_KEY!;
async function ensurePublication() {
// Idempotent: check if it exists first
const list = await fetch(`${BASE}/v1/publications`, {
headers: { Authorization: `Bearer ${KEY}` },
}).then(r => r.json());
const existing = list.publications.find((p: { name: string }) => p.name === "order-events");
if (existing) return existing;
return fetch(`${BASE}/v1/publications`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: "order-events", agents: ["orders", "payments"] }),
}).then(r => r.json());
}
async function ensureSlot(pubId: string, slotName: string) {
const list = await fetch(`${BASE}/v1/publications/${pubId}/slots`, {
headers: { Authorization: `Bearer ${KEY}` },
}).then(r => r.json());
const existing = list.slots.find((s: { name: string }) => s.name === slotName);
if (existing) return existing;
return fetch(`${BASE}/v1/publications/${pubId}/slots`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ name: slotName, cursor_hlc: 0 }),
}).then(r => r.json());
}
function openStream(pubId: string, slotId: string) {
const es = new EventSource(
`${BASE}/v1/publications/${pubId}/stream?slot=${slotId}`
// Pass auth via cookie or a proxy that injects the Authorization header
);
es.addEventListener("record.written", (e) => {
const event = JSON.parse(e.data);
if (event.flags === 2) {
console.log("DELETE", event.agent_id, event.content_hash);
handleDelete(event);
} else {
console.log("WRITE", event.agent_id, event.payload);
handleWrite(event);
}
// cursor advances server-side automatically — no ack needed
});
es.addEventListener("error", () => {
es.close();
setTimeout(() => openStream(pubId, slotId), 2_000);
// Reconnect resumes from the last cursor — no data lost
});
}
const pub = await ensurePublication();
const slot = await ensureSlot(pub.id, "analytics-consumer");
openStream(pub.id, slot.id);Full API reference
Publications
| Method | Path | Description |
|---|---|---|
| POST | /v1/publications | Create a publication |
| GET | /v1/publications | List all publications |
| GET | /v1/publications/:id | Get one publication |
| PATCH | /v1/publications/:id | Update name, agents, or enabled state |
| DELETE | /v1/publications/:id | Delete publication and all its slots |
| GET | /v1/publications/:id/stream?slot=<id> | SSE stream for a subscriber slot |
Subscriber slots
| Method | Path | Description |
|---|---|---|
| POST | /v1/publications/:id/slots | Create a slot (cursor_hlc defaults to 0) |
| GET | /v1/publications/:id/slots | List all slots for this publication |
| GET | /v1/publications/:id/slots/:slot_id | Get one slot |
| DELETE | /v1/publications/:id/slots/:slot_id | Delete a slot |
| POST | /v1/publications/:id/slots/:slot_id/ack | Manually advance cursor to { cursor: <hlc> } |
PATCH fields
{
"name": "new-name", // optional
"agents": ["orders"], // optional — pass null to switch to all-agents
"enabled": false // optional — false disables the stream endpoint
}How cursor tracking works
GET /v1/agents/:id/strand/records?after=<cursor>, then open the stream.