packages/cli/skills/pikku-realtime/SKILL.md
Use Pikku's realtime feature — typed pub/sub events over WebSocket (multi-topic) or SSE (single-topic, auto-cleanup). Covers declaring EventHubTopics, scaffolding the /events channel, the auto-generated `PikkuRealtime` client, and publishing events from a function. TRIGGER when: the user asks for realtime updates, pub/sub, push notifications, server-sent events, websocket events, eventhub, or "live" data on the frontend. DO NOT TRIGGER when: the user wants RPC-style request/response (use pikku-rpc / pikku-react-query) or a custom one-off WebSocket channel (use pikku-websocket).
npx skillsauth add pikkujs/pikku pikku-realtimeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill as an execution checklist, not reference material.
pikku-meta when available; otherwise run the relevant pikku meta ... --json command and inspect only the focused output you need..pikku, node_modules, vendored packages, or broad build artifacts.pikku-verify or pikku all when functions, wirings, schemas, or generated clients may have changed.Most realtime UI is just typed pub/sub: a server pushes todo-created, the
client renders it. Pikku's realtime feature ships exactly that, two ways:
/events — one connection, many topic subscriptions.GET /events/:topic — one connection per topic, auto-cleanup
on disconnect. Good for environments where WebSocket is blocked or for
trivially streaming one topic.Both transports use the same EventHubService and the same publish call.
Choose by transport, not by code shape.
In your project's types file (e.g. types/eventhub-topics.d.ts):
import type { Todo } from '../src/schemas.js'
export type EventHubTopics = {
'todo-created': { todo: Todo }
'todo-updated': { todo: Todo }
'todo-deleted': { todoId: string }
}
Reference it in application-types.d.ts:
import type { EventHubService } from '@pikku/core/channel'
import type { EventHubTopics } from './eventhub-topics.js'
export interface SingletonServices extends CoreSingletonServices<Config> {
eventHub?: EventHubService<EventHubTopics>
// ...
}
And instantiate it in services.ts:
import { LocalEventHubService } from '@pikku/core/channel'
// ...
const eventHub = new LocalEventHubService<EventHubTopics>()
(For multi-instance deployments use CloudflareEventHubService /
LambdaEventHubService / UWSEventHubService instead — same interface.)
yarn pikku enable events # auth required by default
yarn pikku enable events --noAuth # public events
This sets scaffold.events in pikku.config.json. The next pikku all
generates events.gen.ts in your scaffold dir, which wires:
/events handling {action: 'subscribe' | 'unsubscribe', topic} messages.GET /events/:topic.You don't write either by hand. They use whatever eventHub service is
in your singletons.
Add to pikku.config.json:
{
"clientFiles": {
// ...
"realtimeFile": "packages/sdk/src/pikku/realtime.gen.ts",
// Optional: full type inference for subscribe/unsubscribe
"realtimeEventHubTopicsImport": "../../../functions/types/eventhub-topics.js#EventHubTopics",
},
}
Run pikku all (or pikku realtime to regenerate just this file). The
generated file exports two surfaces:
export class PikkuRealtime {
constructor(options: { url: string; reconnect?: boolean; ... })
subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void
unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: ...): void
close(): void
}
export function subscribeToTopicViaSSE<K extends keyof EventHubTopics>(
baseUrl: string, topic: K, handler: (data: EventHubTopics[K]) => void
): { close: () => void }
Without realtimeEventHubTopicsImport, the client falls back to
Record<string, unknown> — usable but untyped. Set the import for full
typed subscribe/unsubscribe.
The /events channel listens for client subscriptions; the eventHub fans
out publishes. Functions publish like this:
import { pikkuFunc } from '#pikku'
export const createTodo = pikkuFunc({
input: CreateTodoInput,
output: CreateTodoOutput,
func: async ({ kysely, eventHub }, data) => {
const todo = await kysely
.insertInto('todos')
.values(data)
.returningAll()
.executeTakeFirstOrThrow()
if (eventHub) {
// Envelope the payload with `topic` so the client dispatcher works.
await eventHub.publish('todo-created', null, {
topic: 'todo-created',
data: { todo },
})
}
return { id: todo.id }
},
})
The null channelId means "broadcast to all subscribers." Pass a
specific channel id to exclude/include a single connection.
A thin helper removes the duplication:
async function publishEvent<K extends keyof EventHubTopics>(
hub: EventHubService<EventHubTopics>,
topic: K,
data: EventHubTopics[K]
) {
return hub.publish(topic, null, { topic, data })
}
// usage:
await publishEvent(eventHub, 'todo-created', { todo })
PikkuRealtime mirrors PikkuRPC: it wraps the same PikkuFetch, so
server URL + auth are configured once and shared across HTTP, RPC,
and realtime transports.
import { createPikku, PikkuProvider } from '@pikku/react'
import { PikkuFetch } from './pikku/pikku-fetch.gen'
import { PikkuRPC } from './pikku/pikku-rpc.gen'
import { PikkuRealtime } from './pikku/realtime.gen'
const pikku = createPikku(
PikkuFetch,
PikkuRPC,
PikkuRealtime, // pass the realtime class as the third arg
{ serverUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:3000' }
)
// pikku.fetch / pikku.rpc / pikku.realtime — all share the same fetch.
createRoot(document.getElementById('root')!).render(
<PikkuProvider pikku={pikku}>
<App />
</PikkuProvider>
)
Or wire manually:
const realtime = new PikkuRealtime()
realtime.setPikkuFetch(pikku.fetch) // inherits serverUrl + auth
import { useEffect, useState } from 'react'
function TodoList() {
const { realtime } = usePikku() // assuming you expose a hook over your context
const [todos, setTodos] = useState<Todo[]>([])
useEffect(() => {
const off = realtime.subscribe('todo-created', ({ todo }) => {
setTodos((prev) => [...prev, todo])
})
return off
}, [realtime])
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)
}
Single-topic SSE (auto-cleanup on close):
useEffect(() => {
const sub = realtime.subscribeToTopic('todo-created', ({ todo }) => {
setTodos((prev) => [...prev, todo])
})
return () => sub.close()
}, [realtime])
Same client also handles generic SSE + channel routes — use the path, the base URL is inherited from PikkuFetch:
// Any sse: true HTTP route
const sub = realtime.subscribeToSSE<{ progress: number }>(
`/workflow-run/${runId}/stream`,
(event) => setProgress(event.progress)
)
// later: sub.close()
// Any wireChannel — open a raw socket, wrap in PikkuWebSocket for typed I/O
const ws = realtime.connectToChannel('/ws/kanban')
const typed = new PikkuWebSocket<'kanban-live'>(ws)
typed.getRoute('command').subscribe('message', (data) => {
/* ... */
})
Discover what's available with pikku meta clients --json — channels
and any HTTP sse: true routes are listed there.
| Need | Use | | ------------------------------------------ | ----------------------------- | | Many topics in one connection | PikkuRealtime (WebSocket) | | Single live stream, simple cleanup | subscribeToTopicViaSSE | | Bidirectional (client also sends messages) | PikkuRealtime | | WebSockets blocked by infra | subscribeToTopicViaSSE |
Both auto-clean on the server (the eventHub's onChannelClosed hook
unsubscribes all topics for the dead channel id). Don't write manual
cleanup unless you're unsubscribing partway through a session.
eventHub.publish(topic, ..., rawData) without the
{topic, data} envelope — clients use topic to dispatch handlers./events channel by hand — pikku enable events
already does it correctly with disconnect cleanup.useEffect. Otherwise
you'll create a subscription per render.EventHubTopics. The
generated client's types prevent it; if you find yourself reaching for
as any to subscribe to a string, declare the topic first.documentation
Deprecated — use pikku-middleware instead. Tag middleware (addTagMiddleware) is now documented as a section within the pikku-middleware skill, alongside global HTTP middleware, execution order, and the service-to-service bearer auth pattern.
testing
Use when adding authorization checks to Pikku functions or routes — pikkuPermission, pikkuAuth, per-function permissions, pattern-based permissions, or understanding OR/AND permission logic. TRIGGER when: user wants to restrict who can call a function, check resource ownership, add role-based access, or understand where permission checks belong. DO NOT TRIGGER when: user asks about middleware or request interception (use pikku-middleware), authentication strategies (use pikku-security), or session management.
testing
Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware (including service-to-service bearer auth), per-route middleware, session-setting middleware, or understanding middleware execution order and priority. TRIGGER when: user wants middleware on some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global interceptors, or middleware priority/order questions. DO NOT TRIGGER when: user asks about permissions/authorization checks (use pikku-permissions), auth strategies like authBearer/authCookie (use pikku-security), or deployment.
documentation
Standard cleanup to run right after a Pikku template is cloned or scaffolded into a new project. TRIGGER when: a Pikku template was just cloned/scaffolded (via `pikku create`, `git clone <template>`, or the user says "I cloned the kanban template / starter / template"), or the working tree still looks like an untouched template (template README, placeholder `@project/*` name in package.json). DO NOT TRIGGER when: working in an established project mid-feature, or editing the template repo itself.