$npx -y skills add inngest/inngest-skills --skill inngest-realtimeUse when streaming durable workflow updates to a UI in real time — live order status pages that animate as steps complete, AI agent token streaming from a function to the browser, log tailing for long-running jobs, or human-in-the-loop approval flows that publish a prompt and wai
| 1 | # Inngest Realtime |
| 2 | |
| 3 | Stream updates from durable Inngest functions to live UIs. Use channels and topics to broadcast progress, render workflow execution as it happens, or build bi-directional human-in-the-loop flows. |
| 4 | |
| 5 | > **These skills are focused on TypeScript.** For Python or Go, refer to the [Inngest documentation](https://www.inngest.com/llms.txt) for language-specific guidance. Core concepts apply across all languages. |
| 6 | |
| 7 | > **⚠ CRITICAL: v3 vs v4 package selection** |
| 8 | > |
| 9 | > Realtime in Inngest v4 lives at the SDK subpath `inngest/realtime`. The standalone `@inngest/realtime` npm package is a **v3-era package** and is **NOT compatible with `inngest@4.x`**. If your project is on v4 (the npm default), do not install `@inngest/realtime`. Use the imports below. |
| 10 | > |
| 11 | > Symptoms of using the wrong package on v4: `TypeError: Cls is not a constructor` on every `PUT /api/inngest`, 401 on subscription tokens, type incompatibility on `new Inngest({ middleware: [...] })`. Verify your `package.json` shows `"inngest": "^4.x"` before reading further. |
| 12 | |
| 13 | ## Prerequisites |
| 14 | |
| 15 | - Inngest v4 SDK installed (`npm install inngest`) — see the `inngest-setup` skill |
| 16 | - `INNGEST_DEV=1` set in `.env.local` for local development (without it, the SDK demands cloud signing keys and 401s on token requests) |
| 17 | - Local Inngest dev server running (`npx inngest-cli@latest dev`) |
| 18 | - Optional: `zod` for schema validation on topics |
| 19 | |
| 20 | ## When to use Realtime |
| 21 | |
| 22 | | Problem shape | Pattern | |
| 23 | |---|---| |
| 24 | | Order status page animates as durable workflow steps complete | Per-run channel, publish per step, client subscribes | |
| 25 | | AI agent streams tokens to a chat UI | Per-conversation channel, publish chunks, stream to browser | |
| 26 | | Log tail for a long-running job | Single channel, log topic, append to UI | |
| 27 | | Human-in-the-loop approval | Channel + waitForEvent, publish prompt, wait for response | |
| 28 | | Admin dashboard with live order list | Global admin channel, fan-out from each function | |
| 29 | |
| 30 | ## Architecture |
| 31 | |
| 32 | Three pieces: |
| 33 | |
| 34 | 1. **Channel definition** — a typed contract for what gets published. Lives in shared module so both server and client can reference the same channel name. |
| 35 | 2. **Publishing** — call `step.realtime.publish` between steps to wrap a durable publish, or `inngest.realtime.publish` inside `step.run` because you're already inside a memoized step. See "Which publish method to use" below. |
| 36 | 3. **Subscribing** — server action mints a subscription token; React client uses the `useRealtime` hook (or the lower-level `subscribe()` API for non-React consumers). |
| 37 | |
| 38 | ## Step 1: Define a channel |
| 39 | |
| 40 | Channels are pure data — no class hierarchy, no zod runtime required (but recommended for type safety). Define them once and import where needed. |
| 41 | |
| 42 | ```typescript |
| 43 | // src/inngest/channels.ts |
| 44 | import { channel } from 'inngest/realtime'; |
| 45 | import { z } from 'zod'; |
| 46 | |
| 47 | // Per-run channel: each fulfill-order run publishes step updates to its own channel. |
| 48 | export const orderChannel = channel({ |
| 49 | name: (orderId: string) => `order:${orderId}`, |
| 50 | topics: { |
| 51 | step: { |
| 52 | schema: z.object({ |
| 53 | name: z.string(), |
| 54 | status: z.enum(['running', 'complete', 'failed']), |
| 55 | output: z.record(z.string(), z.unknown()).optional(), |
| 56 | ts: z.number(), |
| 57 | }), |
| 58 | }, |
| 59 | }, |
| 60 | }); |
| 61 | |
| 62 | // Global admin channel: fan-out for cross-cutting visibility. |
| 63 | export const adminChannel = channel({ |
| 64 | name: 'admin', |
| 65 | topics: { |
| 66 | order: { |
| 67 | schema: z.object({ |
| 68 | orderId: z.string(), |
| 69 | step: z.string(), |
| 70 | status: z.enum(['running', 'complete', 'failed']), |
| 71 | ts: z.number(), |
| 72 | }), |
| 73 | }, |
| 74 | }, |
| 75 | }); |
| 76 | ``` |
| 77 | |
| 78 | **Two channel name shapes:** |
| 79 | - `name: 'admin'` — static channel, accessed as `adminChannel.order` (topic ref) |
| 80 | - `name: (id) => 'channel:${id}'` — parametric, accessed as `orderChannel(id).step` (call the channel def with the id, then access topic) |
| 81 | |
| 82 | ## Step 2: Publish from inside a function |
| 83 | |
| 84 | Inngest v4 ships realtime support natively — **no middleware required.** But where you call `publish` matters: it determines whether the publish is durable, and it's the most common place to get realtime wrong. |
| 85 | |
| 86 | ### Which publish method to use |
| 87 | |
| 88 | | Where you are | Use this | Why | |
| 89 | |---|---|---| |
| 90 | | **Outside a step** (top-level handler code, between `step.run` calls) | `step.realtime.publish(id, topicRef, data)` | Wraps the publish in its own step so it's durable, deduplicated by `id`, and retry-safe. | |
| 91 | | **Inside a step** (inside the callback passed to `step.run`) | `inngest.re |