MCPInstall

inngest-realtime

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.

Installs
803
Last 24h
—
Trending rank
—
Security
Not audited

Install

Works with every agent the skills CLI supports.

npx skills add https://github.com/inngest/inngest-skills --skill inngest-realtime
  1. 01

    Install

    Run the command in your project. The CLI asks which agents to install for.

  2. 02

    Describe the task

    Ask your agent for something this skill covers. It loads inngest-realtime automatically when relevant.

  3. 03

    Review the result

    The agent follows the SKILL.md steps, scripts and references bundled with the skill.

SKILL.md

The instructions your agent reads when the skill is triggered.

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 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 shapePattern
Order status page animates as durable workflow steps completePer-run channel, publish per step, client subscribes
AI agent streams tokens to a chat UIPer-conversation channel, publish chunks, stream to browser
Log tail for a long-running jobSingle channel, log topic, append to UI
Human-in-the-loop approvalChannel + waitForEvent, publish prompt, wait for response
Admin dashboard with live order listGlobal 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.

// 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 areUse thisWhy
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

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

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

// 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:

OptionDefaultUse it when
enabledtrueDelay the subscription until you have an ID (e.g., enabled: !!runId).
bufferInterval0Batch updates from a fast stream so React doesn't re-render per message.
pauseOnHiddenfalsePause the stream when the tab isn't visible (saves bandwidth).
autoCloseOnTerminaltrueDisconnect when the run completes — turn off to keep the stream open for fan-out channels.
historyLimitunboundedCap 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

// 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

// 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:

// 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:

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.

FAQ

What does the inngest-realtime skill do?

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.

How do I install inngest-realtime?

Run npx skills add https://github.com/inngest/inngest-skills --skill inngest-realtime in your project. The skills CLI detects the agents you use and copies the skill into the right folder. For Claude Code you can also place the skill folder at ~/.claude/skills/inngest-realtime/ (personal) or .claude/skills/inngest-realtime/ (project).

Which AI agents can use inngest-realtime?

Any agent that supports the open Agent Skills format (a folder with a SKILL.md file), including Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, Windsurf, Cline, OpenCode and more.

Is inngest-realtime safe to install?

No partner security audit is available yet. Skills can run commands and scripts, so read the SKILL.md and any bundled files before installing.

Who maintains inngest-realtime?

It is published by inngest in inngest/inngest-skills. It has 803 recorded installs.

More from inngest/inngest-skills

View all →

inngest-durable-functions

inngest/inngest-skills

Use when building functions that must survive process crashes, retry automatically on failure, run on a schedule, react to events, or maintain state across infrastructure failures — e.g., webhook handlers that drop events, flaky cron jobs, background jobs that fail mid-execution, or workflows that need to resume where they left off. Covers Inngest function configuration, triggers (events, cron, invoke), step execution and memoization, idempotency, cancellation, error handling, retries, logging, and observability.

DevOps & Cloud3.9K installs

inngest-events

inngest/inngest-skills

Use when designing event-driven workflows, decoupling services, implementing fan-out patterns (one trigger, many downstream handlers), implementing idempotent event handling with IDs (24-hour dedupe window), or handling at-least-once delivery from external sources like Stripe webhooks. Covers Inngest event schema, payload format, naming conventions, IDs for idempotency, the ts param, fan-out patterns, and system events like inngest/function.failed.

Backend & APIs3.8K installs

inngest-steps

inngest/inngest-skills

Use when implementing delays that must survive process restarts (e.g., 24-hour cart abandonment, scheduled follow-ups), waiting for human approval or external events with timeouts (review gates, webhook callbacks, async API completion), polling external services without losing state on crashes, calling other functions and awaiting their results, memoizing expensive operations so they don't re-run on retry, or running async work in parallel inside a workflow. Covers Inngest step methods: step.run, step.sleep, step.waitForEvent, step.waitForSignal, step.sendEvent, step.invoke, step.ai, plus patterns for loops and parallel execution.

Backend & APIs3.8K installs

inngest-setup

inngest/inngest-skills

Use when adding durable execution to a TypeScript project — building retry-safe webhook handlers, background jobs that survive crashes, scheduled tasks, or long-running workflows that outlive a single request. Covers Inngest SDK installation, client config, environment variables, serve endpoints (Next.js, Express, Hono, Fastify), connect-as-worker mode, and the local dev server.

Backend & APIs3.8K installs

inngest-middleware

inngest/inngest-skills

Use when adding cross-cutting concerns to durable functions — structured logging or tracing across all functions, error tracking with Sentry, payload encryption for sensitive data, dependency injection of clients (DB, Stripe, etc.) into function handlers, custom telemetry, or behavior that should apply uniformly across many functions. Covers Inngest middleware lifecycle, creating custom middleware, dependencyInjectionMiddleware, @inngest/middleware-encryption, @inngest/middleware-sentry, and custom middleware patterns.

DevOps & Cloud3.6K installs

inngest-flow-control

inngest/inngest-skills

Use when handling external API rate limits (e.g., OpenAI 429s, HubSpot or Stripe rate limits), preventing duplicate work from rapid event bursts (debouncing user actions), spreading load over time, ensuring per-tenant fairness, processing events in batches, limiting concurrent runs of the same operation, or assigning priority to important runs. Covers Inngest flow control: concurrency limits with keys, throttling, rate limiting, debounce, priority, singleton, and event batching.

Backend & APIs1.6K installs

Popular frontend & design skills

View category →

setup-matt-pocock-skills

mattpocock/skills

Configure this repo for the engineering skills: set up its issue tracker, triage label vocabulary, and domain doc layout. Run once before first use of the other engineering skills.

Frontend & Design1.1M installs

frontend-design

anthropics/skills

Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults.

OfficialFrontend & Design1M installs

hyperframes-animation

heygen-com/hyperframes

All animation knowledge for HyperFrames — atomic motion rules, multi-phase scene blueprints, scene transitions, broader motion-design techniques, AND the seven runtime adapters (GSAP default, plus Lottie, Three.js, Anime.js, CSS keyframes, Web Animations API, TypeGPU). Use for any motion or animation task: pick 2-4 rules and compose, or load a blueprint, or look up runtime-specific API (e.g. GSAP eases / Lottie player / Three.js mixer). Also covers auditing an existing composition's choreography (animation map) and 24 named text-animation effects. HyperFrames-native: single paused timeline, seek-safe, deterministic.

prototype

mattpocock/skills

Build a throwaway prototype to answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.

Frontend & Design999.2K installs

hyperframes-core

heygen-com/hyperframes

The HyperFrames composition contract — build one renderable project. Use for composition structure, the data- timing attributes, class="clip", tracks, sub-compositions, variables, framework-owned media playback, deterministic-render rules, and validation. Read before writing composition HTML.

Frontend & Design930.5K installs

vercel-react-best-practices

vercel-labs/agent-skills

React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.

OfficialFrontend & Design843.1K installs