---
name: adding-a-realtime-channel
description:
    Adds a realtime event/channel to the per-user Socket.IO push surface without breaking the bearer-or-reject, per-user
    room, publisher-cannot-fail, and polling-as-fallback invariants. Use when adding a `RealtimeEvent` variant, wiring a
    new publisher call-site at a domain commit point, invalidating react-query keys on a new event, or gating a new
    `refetchInterval` on `useRealtimeConnected()`.
---

# Adding a realtime channel

Spec at [docs/architecture/realtime.md](../../docs/architecture/realtime.md). This is the procedure; the doc is the
invariants and trade-offs.

The realtime surface is **per-user, bearer-or-reject, polling-as-fallback**. Every one of those is silently breakable --
a broadcast-to-all emit, an unauthed frame, a publisher that throws into the domain op, or a `refetchInterval` that
forgets the connectivity gate each fails without an obvious error. This skill adds a channel while keeping all of them
intact.

## Checklist

```
- [ ] Step 1: Add the event to the wire contract (server registry + web mirror)
- [ ] Step 2: Publish from a domain commit point, per-user, via the root publisher
- [ ] Step 3: Keep the publisher unable to fail the domain op
- [ ] Step 4: Invalidate the smallest react-query keyspace on the web side
- [ ] Step 5: Gate any new refetchInterval on useRealtimeConnected()
- [ ] Step 6: Confirm REDIS_URL-dark degrades to polling, not breakage
- [ ] Step 7: Test -- per-user scoping, cross-instance fan-out, no leak
```

## Step 1: Wire contract

The event is a typed `RealtimeEvent` variant -- add it in **two** places that mirror each other:

- Server: [`apps/api/src/websockets/event-registry.ts`](../../apps/api/src/websockets/event-registry.ts) -- the
  discriminated-union variant + its zod schema. This is the single source of truth for the wire contract.
- Web: [`apps/web/src/realtime/types.ts`](../../apps/web/src/realtime/types.ts) -- the slim union mirror (duplicated,
  not a runtime-zod import, to keep the web bundle slim). Keep it in lockstep with the registry.

## Step 2: Publish from a domain commit point

Publish where the domain handler commits state -- an indexer event handler
([`apps/api/src/websockets/`](../../apps/api/src/websockets/) call-sites live in the onchain-events handlers) or a route
handler. **Never from a request middleware.** Emit through the module-singleton root publisher:

```ts
getRootPublisher().publishToUser(userId, event);
```

Read it via the getter ([`root.ts`](../../apps/api/src/websockets/root.ts)) rather than threading a publisher arg
through factory chains -- mirrors the `setRootLogger` / `getLogger` seam. Indexer-driven handlers receive on-chain
addresses; map address -> userId via [`resolve-user.ts`](../../apps/api/src/websockets/resolve-user.ts) (batched
`user_wallet` lookup) before scoping the per-user emit. There is **no broadcast-to-all path**: the publisher emits to
`user:<userId>` only, and the Redis adapter fans that same room across replicas.

## Step 3: Publisher cannot fail the domain op

The underlying chain event / DB write has already landed by the time the publisher runs. The publisher
([`publisher.ts`](../../apps/api/src/websockets/publisher.ts)) validates the payload against the registry schema and
**logs + drops** on failure; the gateway publish is wrapped so an adapter blip (transient Redis outage) logs and carries
on. Do not let a publish error propagate into the domain transaction.

## Step 4: Invalidate the smallest react-query keyspace

In [`use-realtime-events.ts`](../../apps/web/src/realtime/use-realtime-events.ts), handle the new event by invalidating
the **smallest** matching keyspace -- not a blanket reset. Follow the existing granularity (broad `['web3']` +
`['activity']` for lifecycle events; pinned keys like `['web3', 'matured']` for a specific change). Over-broad
invalidation defeats the point of the targeted push.

## Step 5: Gate new polling

Polling is the fallback, not the path. Any `refetchInterval` you add for data this channel now pushes must read
[`useRealtimeConnected()`](../../apps/web/src/realtime/context.tsx) and switch to `false` while the socket is up,
resuming the previous interval on disconnect. The exception is data the kit does not publish (e.g. viem-driven on-chain
balance reads) -- those stay polling and are not gated.

## Step 6: REDIS_URL-dark path

`REDIS_URL` gates the gateway. Without it boot installs `createNoopPublisher()`, so `publishToUser(...)` silently drops
and the client never connects -- the polling fallback (Step 5) is what keeps the data fresh. Confirm the channel
degrades to polling rather than erroring when the env is absent; this mirrors the rate-limit / oauth-stash dependence on
the same env -- no separate plumbing.

## Step 7: Test

Per [`authoring-tests`](../authoring-tests/SKILL.md), with the realtime-specific asserts the gateway suite already
establishes:

- Per-user scoping: a publish to `user:<A>` does not reach `user:<B>`.
- Cross-instance fan-out: two `mountRealtimeGateway` calls on different ports sharing a `REDIS_URL`; a publish on
  instance A reaches a client on instance B (allow for the first-publish handshake window with a polled wait).
- No leak: a connect/disconnect cycle returns `connectionCount()` to zero before the next assertion.
- Unauthed upgrade rejects on the `connect_error` path before any frame.

## What this skill is NOT

- It does not add the auth-on-upgrade middleware or the room model -- those exist; a channel rides them.
- It does not own observability primitives -- adding a log/span/counter around the publish is
  [`adding-observability`](../adding-observability/SKILL.md).
