Quickstart
The fastest way to understand CNS is to emit one event and watch it come back. This
page uses the repo's dev fixture — a local cns-server with no cloud
credentials — so you can follow along without provisioning ClickHouse, Centrifugo, or
AWS SSM first. For a real deployment, see
Self-Hosting & Operations.
:::note Prerequisites
A Go 1.25.5+ toolchain (to build cns-server locally) and curl. Node.js if you want
to run the TypeScript snippets below.
:::
1. Start an instance
From the repository root:
scripts/dev-fixture.sh
This script (verified in AGENTS.md's 2026-09-16 dev-fixture changelog entry):
- builds and boots
cns-serveron127.0.0.1:8098with an in-memory event store, an in-memory ed25519 keystore (never AWS SSM), thesso-jwtidentity adapter using a fixed local HMAC secret, and the realtime adapter left unconfigured (noop) — explicitly unsetting anyAWS_*/ClickHouse/Centrifugo variables from your shell so the fixture can never touch real infrastructure; - seeds three example events so
GET /admin/v1/activityhas something to show immediately; - mints a bearer token for a fixed sample subject and grants it
tenant-admin; - prints where it wrote that token and blocks in the foreground (run the script itself in the background if you want the server to keep running).
The fixture hardcodes its sample identity — subject sub_dev_fixture, tenant
katyayani (a fixed literal in the script, not something this guide chose) — and
writes the minted token to the path named by $TOKEN_FILE (the script prints the
exact path it used). Export it for the rest of this walkthrough:
export CNS_DEV_API="http://127.0.0.1:8098"
export TOKEN="$(cat <path the script printed>)"
Confirm it's up:
curl -s "$CNS_DEV_API/healthz"
# -> ok
curl -s -H "Authorization: Bearer $TOKEN" "$CNS_DEV_API/admin/v1/me"
# -> {"subject_id":"sub_dev_fixture","tenant_id":"katyayani","roles":["tenant-admin","platform-operator"]}
:::info Not ready to run it locally?
The rest of this page works identically against any running cns-server — swap
CNS_DEV_API/$TOKEN for your instance's URL and a real bearer token (see
Authentication and
Managing apps & key rotation).
:::
2. Emit your first event
This walkthrough authenticates as an end user (a subject principal) — the token above is exactly that shape. A backend service emitting on its own behalf uses an app/publisher service-JWT instead; see Emitting events: app vs. subject principal.
- TypeScript SDK
- curl
import { CNSClient } from "@amphoze/cns-client";
const client = new CNSClient({
baseUrl: process.env.CNS_DEV_API!, // e.g. "http://127.0.0.1:8098"
getToken: () => process.env.TOKEN!,
});
const event = await client.emit("quickstart.hello", {
data: { message: "hello from the quickstart" },
});
console.log(event.event_id, event.seq, event.provenance);
// -> "01J...", 0, "client-asserted"
:::caution Not yet published
@amphoze/cns-client is implemented (sdk/ts/packages/client) but not yet
published to a package registry (workspace version 0.1.0). Until it is, consume
it from within the sdk/ts npm workspace, or build it (npm run build in
sdk/ts/) and reference the resulting dist/ directly. This is a real gap, not an
oversight — flagging it here rather than a working npm install command.
:::
curl -s -X POST "$CNS_DEV_API/v1/events" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_type": "quickstart.hello",
"data": { "message": "hello from the quickstart" }
}'
202 Accepted with the fully server-stamped envelope — event_id, tenant_id,
actor.subject_id, ingest_time, seq, and provenance are all filled in
server-side; the client only ever supplied event_type and data. See
Events & the envelope for every field.
Every event lands in the chronological store immediately, whether or not any policy matches it as notify-worthy (see Policies) — that's why the next two steps work with no extra setup.
3. Read it back from the feed
curl -s -H "Authorization: Bearer $TOKEN" "$CNS_DEV_API/v1/feed" | python3 -m json.tool
{
"Events": [
{
"event_id": "01J...",
"tenant_id": "katyayani",
"event_type": "quickstart.hello",
"actor": { "subject_id": "sub_dev_fixture" },
"target": { "subject_ids": [] },
"provenance": "client-asserted",
"delivery": "instant",
"event_time": "2026-09-20T10:00:00Z",
"ingest_time": "2026-09-20T10:00:00.123Z",
"session_id": "...",
"seq": 0,
"data": { "message": "hello from the quickstart" }
}
// ...the 3 seeded example events...
],
"NextCursor": "",
"HasMore": false
}
:::caution Verified wire shape: PascalCase, not the lowercase in ROUTES.md
GET /v1/feed serializes adapters/store.PageResult directly
(json.NewEncoder(w).Encode(result) in api/handlers.go), and that Go struct has
no JSON tags — so it marshals as Events/NextCursor/HasMore (Go's default,
PascalCase), not the lowercase events/next_cursor/has_more that both
api/ROUTES.md's prose and the TypeScript SDK's PageResult type currently claim.
This is a real, source-verified discrepancy between the docs/SDK and the running
server, not a documentation choice — see the caution box in
API Reference → Data plane for the full detail and
what it means for @amphoze/cns-core's PageResult type.
:::
GET /v1/feed defaults to the caller's own subject_id when none is given, and
matches events where the caller is actor or target — which is why the event you
just emitted (actor = you) shows up immediately.
4. Watch it live
curl -N -H "Authorization: Bearer $TOKEN" "$CNS_DEV_API/v1/stream"
In a second terminal, emit another event (step 2 again) — with a configured
realtime adapter, it would arrive on the open connection as one
data: <event JSON>\n\n SSE frame per event, matching the caller's own subject by
default.
:::caution Won't work against the plain dev fixture — verified, not a fixture bug
internal/adapters/realtime/noop's AuthorizeSubscribe fails closed (returns
Deny) by design, and GET /v1/stream calls it as a second authorization check
whenever a realtime adapter is wired at all (cmd/cns-server's buildRealtime
always wires something — noop when unconfigured, never nil). The dev fixture
never sets CNS_CENTRIFUGO_ADDR, so its realtime adapter is noop — meaning
GET /v1/stream returns 403 against an out-of-the-box fixture, regardless of
whether you're authorized to read the feed. This is a genuine Phase-1 gap, verified
by reading internal/adapters/realtime/noop/noop.go and api/handlers.go's
getStream directly, not documented elsewhere as of this writing. To see this step
actually stream, point CNS_CENTRIFUGO_ADDR at a real Centrifugo instance (see
Infrastructure setup).
:::
Also note: GET /v1/stream and the TS SDK's CNSClient.subscribe() are two
different transports, not the same feature via two doors — see
Realtime & streaming before you reach for
subscribe() expecting it to hit this endpoint.
Next steps
- Understand the shape you just posted: Events & the envelope.
- Make an event actually notify someone: Authoring notification policies.
- Emit as a backend service instead of an end user: Emitting events.
- Every route used above, in full: API Reference.