Agent Native

inngest-realtime

Stream live updates from Inngest v4 durable workflows to browser UIs via typed channels and topics.

by InngestOfficial publisherNot reviewed

Identity proven by GitHub's verified organisation badge (inngest)

Agents
Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode, claude.ai and API
License
Apache-2.0 (permissive)
Pin
082798apinned Oct 9, 2026
Context cost
about 4,500 tokenswhen the skill loads
GitHub stars
28on inngest/inngest-skills
Scripts
None found

What it does

“Use 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 wait for a user reply. Covers Inngest v4 native realtime: defining typed channels, publishing from inside step.run, minting subscription tokens via server actions, and consuming the stream from React/Next.js client components.”
The author's description, from SKILL.md

Generated summary from the skill's own text

Stream live updates from Inngest v4 durable workflows to browser UIs via typed channels and topics. Publish step progress, AI token streams, or approval prompts from inside functions, mint subscription tokens in server actions, and consume updates with React hooks or manual subscribe APIs.

When to use it

  • Animate order status pages as workflow steps complete in real time
  • Stream AI agent token output to chat UIs from long-running functions
  • Build human-in-the-loop approval flows with durable wait-for-response patterns
  • Display live logs or dashboards of job execution progress

When not to

  • Project uses Inngest v3 (use @inngest/realtime package instead)
  • Non-TypeScript projects (Python or Go need language-specific approaches)

Install

These commands install from the publisher's repository and need no account.

skills CLI

npx skills add inngest/inngest-skills --skill inngest-realtime

Installs from the repository's default branch, which may have changed since the commit we checked.

GitHub CLI 2.90 or later

gh skill install inngest/inngest-skills inngest-realtime@082798a627bbe9c3ed3a6638a9f56116a035f122

Installs the commit we checked (082798a).

Copy by hand

Download the folder at 082798a and put it in .claude/skills/inngest-realtime for Claude Code and in .agents/skills/inngest-realtime for Codex, Cursor, Gemini CLI, GitHub Copilot and OpenCode.

Skill folder on GitHub at 082798a

Copy for your agent

A prompt for any coding agent. It installs this skill from GitHub at the pinned commit, copies the files as they are, and runs nothing unless you ask.

Prompt

Install the agent skill "inngest-realtime" by Inngest.

Source, pinned to commit 082798a627bbe9c3ed3a6638a9f56116a035f122:
https://github.com/inngest/inngest-skills/tree/082798a627bbe9c3ed3a6638a9f56116a035f122/skills/inngest-realtime

1. Download that folder at exactly this commit. Do not use the default branch.
2. Copy every file unchanged, keeping the folder structure, into .claude/skills/inngest-realtime/ for Claude Code and .agents/skills/inngest-realtime/ for Codex, Cursor, Gemini CLI, GitHub Copilot and OpenCode. Create the folders if they do not exist.
3. Do not edit the files, and do not run any script in the folder unless I ask you to.
4. When you are done, list the files you copied and where you put them.

Listing and checks: https://www.agentnative.dev/skills/inngest/inngest-realtime-inngest-skills

SKILL.md at the pin

---
name: inngest-realtime
description: "Use 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 wait for a user reply. Covers Inngest v4 native realtime: defining typed channels, publishing from inside step.run, minting subscription tokens via server actions, and consuming the stream from React/Next.js client components."
---

# Inngest Realtime

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.

> **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.

> **⚠ CRITICAL: v3 vs v4 package selection**
>
> 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.
>
> 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.

## Prerequisites

- Inngest v4 SDK installed (`npm install inngest`) — see the `inngest-setup` skill
- `INNGEST_DEV=1` set in `.env.local` for local development (without it, the SDK demands cloud signing keys and 401s on token requests)
- Local Inngest dev server running (`npx inngest-cli@latest dev`)
- Optional: `zod` for schema validation on topics

## When to use Realtime

| Problem shape | Pattern |
|---|---|
| Order status page animates as durable workflow steps complete | Per-run channel, publish per step, client subscribes |
| AI agent streams tokens to a chat UI | Per-conversation channel, publish chunks, stream to browser |
| Log tail for a long-running job | Single channel, log topic, append to UI |
| Human-in-the-loop approval | Channel + waitForEvent, publish prompt, wait for response |
| Admin dashboard with live order list | Global admin channel, fan-out from each function |

## Architecture

Three pieces:

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.
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.
3. **Subscribing** — server action mints a subscription token; React client uses the `useRealtime` hook (or the lower-level `subscribe()` API for non-React consumers).

## Step 1: Define a channel

Channels are pure data — no class hierarchy, no zod runtime required (but recommended for type safety). Define them once and import where needed.

```typescript
// src/inngest/channels.ts
import { channel } from 'inngest/realtime';
import { z } from 'zod';

// Per-run channel: each fulfill-order run publishes step updates to its own channel.
export const orderChannel = channel({
  name: (orderId: string) => `order:${orderId}`,
  topics: {
    step: {
      schema: z.object({
        name: z.string(),
        status: z.enum(['running', 'complete', 'failed']),
        output: z.record(z.string(), z.unknown()).optional(),
        ts: z.number(),
      }),
    },
  },
});

// Global admin channel: fan-out for cross-cutting visibility.
export const adminChannel = channel({
  name: 'admin',
  topics: {
    order: {
      schema: z.object({
        orderId: z.string(),
        step: z.string(),
        status: z.enum(['running', 'complete', 'failed']),
        ts: z.number(),
      }),
    },
  },
});
```

**Two channel name shapes:**
- `name: 'admin'` — static channel, accessed as `adminChannel.order` (topic ref)
- `name: (id) => 'channel:${id}'` — parametric, accessed as `orderChannel(id).step` (call the channel def with the id, then access topic)

## Step 2: Publish from inside a function

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.

### Which publish method to use

| Where you are | Use this | Why |
|---|---|---|
| **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. |
| **Inside a step** (inside the callback passed to `step.run`) | `inngest.realtime.publish(topicRef, data)` | You're already inside a memoized step. `step.realtime.publish` would create a step inside a step. The bare client publish is the right call here. |
| **Outside a function** (one-off route, script, etc.) | `inngest.realtime.publish(topicRef, data)` | Allowed, but **not retry-safe** — your client receiver must handle duplicates. |

The 90% rule: if you're writing handler code and you reach for `publish`, use `step.realtime.publish`. If you're writing code inside a `step.run` block and you reach for `publish`, use `inngest.realtime.publish`.

### Example: both patterns in one function

```typescript
// src/inngest/functions/fulfill-order.ts
import { inngest } from '../client';
import { orderChannel, adminChannel } from '../channels';

export const fulfillOrder = inngest.createFunction(
  {
    id: 'fulfill-order',
    retries: 3,
    triggers: [{ event: 'store/order.placed' }],
  },
  async ({ event, step }) => {
    const { orderId, customerEmail, lineItems } = event.data;

    // Outside any step.run — use step.realtime.publish for a durable wrapper.
    const emit = async (
      name: string,
      status: 'running' | 'complete' | 'failed',
      output?: Record<string, unknown>,
    ) => {
      const ts = Date.now();
      await step.realtime.publish(
        `emit-order-${name}-${status}`,
        orderChannel(orderId).step,
        { name, status, output, ts },
      );
      await step.realtime.publish(
        `emit-admin-${name}-${status}`,
        adminChannel.order,
        { orderId, step: name, status, ts },
      );
    };

    await emit('capture-payment', 'running');

    // Inside step.run — use inngest.realtime.publish (already in a memoized step).
    const payment = await step.run('capture-payment', async () => {
      const intent = await stripe.paymentIntents.create({ /* ... */ });

      // Stream a partial update mid-step. No step-in-step wrapping needed.
      await inngest.realtime.publish(orderChannel(orderId).step, {
        name: 'capture-payment',
        status: 'running',
        output: { stage: 'intent-created', intentId: intent.id },
        ts: Date.now(),
      });

      return await stripe.paymentIntents.confirm(intent.id);
    });

    await emit('capture-payment', 'complete', payment);

    await emit('reserve-inventory', 'running');
    const inventory = await step.run('reserve-inventory', async () => {
      // ...
    });
    await emit('reserve-inventory', 'complete', inventory);

    // ...
  },
);
```

**Why no middleware:** Earlier versions used `@inngest/realtime`'s `realtimeMiddleware()` to inject a `publish` arg into the handler. v4 puts it on `step.realtime` and `inngest.realtime` directly.

## Step 3: Mint a subscription token (server action)

In Next.js App Router, use a Server Action to securely mint a short-lived token for the React hook in Step 4. Without a token, clients can't subscribe.

```typescript
// src/app/orders/[orderId]/actions.ts
'use server';

import { getClientSubscriptionToken } from 'inngest/react';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';

export async function fetchOrderSubscriptionToken(orderId: string) {
  // ⚠ AUTHORIZATION GATE: verify the current user owns this orderId
  // before minting a token. Channels are addressable by ID, so without
  // an ownership check, anyone can subscribe to any order's stream by
  // guessing IDs.
  //
  //   const session = await getServerSession();
  //   if (!session) throw new Error('Unauthenticated');
  //   const order = await db.order.findUnique({ where: { id: orderId } });
  //   if (order?.userId !== session.userId) throw new Error('Forbidden');

  return getClientSubscriptionToken(inngest, {
    channel: orderChannel(orderId),
    topics: ['step'],
  });
}
```

`getClientSubscriptionToken` from `inngest/react` returns a token shape that the `useRealtime` hook in Step 4 consumes directly. No ChannelInstance stripping needed — that gotcha only applies to the lower-level `getSubscriptionToken` + manual `subscribe()` path (see "Pattern: Manual subscribe" below).

## Step 4: Subscribe with the `useRealtime` hook

The recommended consumer for React/Next.js is the `useRealtime` hook from `inngest/react`. It handles the subscription lifecycle, reconnect, type narrowing per topic, and cleanup.

```typescript
// src/components/OrderStatusClient.tsx
'use client';

import { useRealtime } from 'inngest/react';
import { orderChannel } from '@/inngest/channels';
import { fetchOrderSubscriptionToken } from '@/app/orders/[orderId]/actions';

export function OrderStatusClient({ orderId }: { orderId: string }) {
  const { messages, connectionStatus, error } = useRealtime({
    channel: orderChannel(orderId),
    topics: ['step'] as const,
    token: () => fetchOrderSubscriptionToken(orderId),
  });

  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <div>Status: {connectionStatus}</div>
      <ul>
        {messages.all.map((m, i) => (
          <li key={i}>
            {(m.data as { name: string }).name}: {(m.data as { status: string }).status}
          </li>
        ))}
      </ul>
    </div>
  );
}
```

**Useful options on the hook:**

| Option | Default | Use it when |
|---|---|---|
| `enabled` | `true` | Delay the subscription until you have an ID (e.g., `enabled: !!runId`). |
| `bufferInterval` | `0` | Batch updates from a fast stream so React doesn't re-render per message. |
| `pauseOnHidden` | `false` | Pause the stream when the tab isn't visible (saves bandwidth). |
| `autoCloseOnTerminal` | `true` | Disconnect when the run completes — turn off to keep the stream open for fan-out channels. |
| `historyLimit` | unbounded | Cap how many messages are retained in `messages.all`. |

The hook returns `messages.byTopic` (latest per topic), `messages.all` (full history), `messages.last` (most recent), and `messages.delta` (new since last render).

## Pattern: Manual subscribe (non-React or custom transport)

The `useRealtime` hook covers the React case. If you're not using React, or you need a custom subscription lifecycle (server-side streaming, background workers, custom protocols), use the lower-level `subscribe()` API directly.

### Server action: mint a token with the lower-level helper

```typescript
// src/app/orders/[orderId]/actions.ts
'use server';

import { getSubscriptionToken } from 'inngest/realtime';
import { inngest } from '@/inngest/client';
import { orderChannel } from '@/inngest/channels';

export async function fetchOrderSubscriptionTokenLowLevel(orderId: string) {
  // ⚠ AUTHORIZATION GATE: same as Step 3 — verify ownership before minting.

  const token = await getSubscriptionToken(inngest, {
    channel: orderChannel(orderId),
    topics: ['step'],
  });

  // ⚠ CRITICAL: strip the ChannelInstance from the response.
  // getSubscriptionToken returns { channel: ChannelInstance, ... } where
  // ChannelInstance contains zod schema methods (a class with prototypes).
  // Next.js refuses to serialize classes across the server-action → client-component
  // boundary, so return ONLY primitives.
  return {
    channel: orderChannel(orderId).name as string,
    topics: ['step'] as const,
    key: token.key,
    apiBaseUrl: token.apiBaseUrl,
  };
}
```

### Manual client subscription

```typescript
// src/components/OrderStatusManual.tsx
'use client';

import * as React from 'react';
import { subscribe } from 'inngest/realtime';
import { fetchOrderSubscriptionTokenLowLevel } from '@/app/orders/[orderId]/actions';

export function OrderStatusManual({ orderId }: { orderId: string }) {
  const [messages, setMessages] = React.useState<unknown[]>([]);

  React.useEffect(() => {
    let cancelled = false;
    let sub: { close?: (reason?: string) => void } | undefined;

    (async () => {
      const token = await fetchOrderSubscriptionTokenLowLevel(orderId);
      if (cancelled) return;

      sub = await subscribe(
        {
          channel: token.channel,
          topics: [...token.topics],
          key: token.key,
          apiBaseUrl: token.apiBaseUrl,
        },
        (message) => {
          if (cancelled) return;
          setMessages((prev) => [...prev, message.data]);
        },
      );
    })();

    return () => {
      cancelled = true;
      sub?.close?.('unmount');
    };
  }, [orderId]);

  // ... render ...
}
```

### SSE streaming from a route handler

Subscribe inside a Next.js API route and pipe the stream to the client via SSE:

```typescript
// src/app/api/orders/[orderId]/stream/route.ts
import { inngest } from '@/inngest/client';
import { subscribe } from 'inngest/realtime';
import { orderChannel } from '@/inngest/channels';

export async function GET(req: Request, { params }: { params: { orderId: string } }) {
  // ⚠ AUTHORIZATION GATE: same rule as the server-action token mint above.
  // Authenticate the request and confirm the caller owns params.orderId
  // before opening the SSE stream. Skipping this leaks every order's
  // step events to anyone with a URL.

  const stream = await subscribe({
    app: inngest,
    channel: orderChannel(params.orderId),
    topics: ['step'],
  });

  return new Response(stream.getEncodedStream(), {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}
```

Client consumes via `fetch().getReader()` rather than the `subscribe()` callback. Use this when you want the SSE behavior or when the client-side `subscribe()` API doesn't fit your component lifecycle.

## Pattern: Human-in-the-loop

Combine `step.realtime.publish` with `step.waitForEvent`:

```typescript
import crypto from 'crypto';

export const reviewWorkflow = inngest.createFunction(
  { id: 'review-workflow', triggers: [{ event: 'review/start' }] },
  async ({ event, step }) => {
    const confirmationId = await step.run('gen-id', () => crypto.randomUUID());

    // Publish a prompt — the client subscribes and renders an approval UI
    await step.realtime.publish(
      'publish-prompt',
      reviewChannel.message,
      { message: 'Confirm to proceed?', confirmationId },
    );

    // Wait up to 15 minutes for the user to send the matching event back
    const confirmation = await step.waitForEvent('await-confirmation', {
      event: 'review/confirmation',
      timeout: '15m',
      if: `async.data.confirmationId == "${confirmationId}"`,
    });

    if (!confirmation) {
      // user didn't respond — abort or escalate
      return { decision: 'timed_out' };
    }
    // continue workflow...
  },
);
```

The `confirmationId` links the published prompt to the matching reply, so the workflow knows which response to act on.

## Common pitfalls

### Don't use `@inngest/realtime` on v4

The standalone `@inngest/realtime` package is for Inngest v3 only. On v4, all realtime APIs are in the SDK subpath `inngest/realtime`. Mixing them produces:
- `TypeError: Cls is not a constructor` on `PUT /api/inngest` (v3 middleware class signature mismatch)
- 401 Unauthorized on subscription tokens
- TypeScript errors casting middleware

**Verify with:** `grep '"inngest"' package.json` — if it's `^4.x`, use `inngest/realtime`. Period.

### Don't return ChannelInstance from a Next.js server action (manual subscribe path only)

`getSubscriptionToken` returns `{ channel: ChannelInstance, ... }` where ChannelInstance has zod schema methods (a class). Next.js refuses to serialize classes across the server-action → client-component boundary. Strip to primitives before returning. See "Pattern: Manual subscribe" above.

This gotcha does **not** apply when you use `getClientSubscriptionToken` from `inngest/react` (Step 3 — the recommended path). That helper returns a serialization-safe shape directly.

### `INNGEST_DEV=1` is required for local dev

Without it, the SDK assumes cloud mode and demands `INNGEST_SIGNING_KEY` + `INNGEST_EVENT_KEY`. All realtime operations 401 / 500. Add to `.env.local`. Hard restart the dev server (Next.js does not hot-reload `.env.local` changes).

### Channel topic schemas validate on publish, not on consume

If your published payload doesn't match the zod schema, the publish fails server-side. Subscriber receives nothing. Catch publish errors during step execution, or run with `validate: false` in `subscribe()` if you have a reason to skip schema validation client-side.

## Reference

- v4 entry points:
  - `import { channel } from 'inngest/realtime'` — channel definitions
  - `import { useRealtime, getClientSubscriptionToken } from 'inngest/react'` — React hook + matching token helper (Step 3 + Step 4)
  - `import { getSubscriptionToken, subscribe } from 'inngest/realtime'` — lower-level helpers for non-React or custom transport
- Publish methods:
  - **Outside a step:** `step.realtime.publish(id, topicRef, data)` — wraps in a durable step
  - **Inside `step.run`:** `inngest.realtime.publish(topicRef, data)` — already inside a memoized step, no wrapping needed
  - **Outside a function:** `inngest.realtime.publish(topicRef, data)` — allowed but not retry-safe
- Subscribe overloads: `subscribe(token)` returns a stream; `subscribe(token, callback)` invokes callback per message
- Next.js Server Action gotcha (manual path only): strip `ChannelInstance` → return `{ channel: string, topics, key, apiBaseUrl }`. Not needed with `getClientSubscriptionToken`.

From inngest/inngest-skills at 082798a, shown as plain text under its license: Apache-2.0 (permissive). The license text is in LICENSE.

View SKILL.md on GitHub at 082798a

Files

1 file, 19 KB in total. Each file has a sha256 in the pin's manifest; the first 12 characters are shown.

Skill folder on GitHub at 082798a

Also in

The same SKILL.md content appears in these places too. We list it once and credit the oldest public source.

All 18 skills from Inngest

More from Inngest

  • inngest-agent-evals

    Builds production evaluation workflows for AI agents on Inngest using direct and deferred scoring, sessions for grouping related runs, step experiments for…

    Claude Code, Codex, Cursor +4 · about 2.6K tokens · pinned 6 days ago

  • inngest-agents

    Build durable AI agents and multi-step agentic workflows using Inngest and AgentKit.

    Claude Code, Codex, Cursor +4 · about 2.1K tokens · pinned 6 days ago

  • inngest-api-cli

    This skill routes the agent to use the Inngest CLI `inngest api` command for operational tasks like debugging runs, querying events, syncing apps, invoking…

    Claude Code, Codex, Cursor +4 · about 1.9K tokens · pinned 6 days ago

  • inngest-api

    This skill enables agents to interact with the Inngest REST API v2 for Cloud operations, raw HTTP requests, and API reference lookups.

    Claude Code, Codex, Cursor +4 · about 1.1K tokens · pinned 6 days ago

  • inngest-brownfield-audit

    This skill audits existing TypeScript or JavaScript codebases to identify where Inngest can improve reliability and durability.

    Claude Code, Codex, Cursor +4 · about 2.5K tokens · pinned 6 days ago

  • inngest-cli

    This skill enables installation and operation of the Inngest CLI for local development and testing with the Dev Server, which provides a browser UI at…

    Claude Code, Codex, Cursor +4 · about 3.4K tokens · pinned 6 days ago

All Backend and APIs skills
  • supabase

    This skill enables the agent to work with Supabase products including Database, Auth, Edge Functions, Realtime, Storage, and Vectors.

    by SupabaseOfficial publisher

    Claude Code, Codex, Cursor +4 · about 3.3K tokens · pinned 6 days ago

  • flags-sdk

    The Flags SDK is a feature flags toolkit for Next.js and SvelteKit that declares flags as functions and evaluates them server-side.

    by VercelOfficial publisher

    Claude Code, Codex, Cursor +3 · about 5.8K tokens · pinned 6 days ago

  • clerk

    This skill routes Clerk authentication tasks to specialized sub-skills based on the user's specific need, such as framework setup, CLI operations, custom UI…

    by ClerkOfficial publisher

    Claude Code, Codex, Cursor +4 · about 1.4K tokens · pinned 6 days ago

  • neon-auth

    Adds authentication to Next.js, React SPA, or Node.js applications using the Neon Auth package.

    by NeonOfficial publisher

    Contains scripts

    Claude Code, Codex, Cursor +3 · about 1.2K tokens · pinned 6 days ago

  • supabase

    This skill enables agents to work with Supabase across databases, authentication, edge functions, storage, realtime services, and related client libraries.

    by SupabaseOfficial publisher

    Claude Code, Codex, Cursor +4 · about 3.3K tokens · pinned 6 days ago

  • inngest-setup

    This skill sets up Inngest, a durable execution platform, in TypeScript projects for building retry-safe webhook handlers, background jobs, and scheduled tasks.

    by InngestOfficial publisher

    Claude Code, Codex, Cursor +4 · about 2.9K tokens · pinned 6 days ago

Open roles at Inngest

No open roles at Inngest on the Agent Native jobs board right now.

Browse the jobs board

The skills listed here are third-party software, collected from public GitHub repositories as a catalog of recent skills and provided as is. Agent Native does not write or control them and is not responsible for any harm, loss or damage caused by installing or using them. Labels and checks describe what we saw at one commit and are not a guarantee. Read a skill's files before you run it. Terms of use