diff --git a/CLAUDE.md b/CLAUDE.md index 5c36f38b02..1a2debc10e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,8 @@ The `rivet.gg` domain is deprecated and should never be used in this codebase. +**ALWAYS use `github.com/rivet-dev/rivet` - NEVER use `rivet-dev/rivetkit` or `rivet-gg/*`** + ## Commands ### Build Commands diff --git a/website/package.json b/website/package.json index 404c1b159e..36130913ce 100644 --- a/website/package.json +++ b/website/package.json @@ -25,7 +25,6 @@ "@fortawesome/free-brands-svg-icons": "^7.1.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@fortawesome/react-fontawesome": "^3.1.0", - "@giscus/react": "^3.1.0", "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.2.0", "@rivet-gg/api": "25.5.3", diff --git a/website/public/giscus.css b/website/public/giscus.css deleted file mode 100644 index f33c5bd215..0000000000 --- a/website/public/giscus.css +++ /dev/null @@ -1,151 +0,0 @@ -/*! MIT License - * Copyright (c) 2018 GitHub Inc. - * https://github.com/primer/primitives/blob/main/LICENSE - */ - -main { - --color-prettylights-syntax-comment: #8b949e; - --color-prettylights-syntax-constant: #79c0ff; - --color-prettylights-syntax-entity: #d2a8ff; - --color-prettylights-syntax-storage-modifier-import: #c9d1d9; - --color-prettylights-syntax-entity-tag: #7ee787; - --color-prettylights-syntax-keyword: #ff7b72; - --color-prettylights-syntax-string: #a5d6ff; - --color-prettylights-syntax-variable: #ffa657; - --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; - --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; - --color-prettylights-syntax-invalid-illegal-bg: #8e1519; - --color-prettylights-syntax-carriage-return-text: #f0f6fc; - --color-prettylights-syntax-carriage-return-bg: #b62324; - --color-prettylights-syntax-string-regexp: #7ee787; - --color-prettylights-syntax-markup-list: #f2cc60; - --color-prettylights-syntax-markup-heading: #1f6feb; - --color-prettylights-syntax-markup-italic: #c9d1d9; - --color-prettylights-syntax-markup-bold: #c9d1d9; - --color-prettylights-syntax-markup-deleted-text: #ffdcd7; - --color-prettylights-syntax-markup-deleted-bg: #67060c; - --color-prettylights-syntax-markup-inserted-text: #aff5b4; - --color-prettylights-syntax-markup-inserted-bg: #033a16; - --color-prettylights-syntax-markup-changed-text: #ffdfb6; - --color-prettylights-syntax-markup-changed-bg: #5a1e02; - --color-prettylights-syntax-markup-ignored-text: #c9d1d9; - --color-prettylights-syntax-markup-ignored-bg: #1158c7; - --color-prettylights-syntax-meta-diff-range: #d2a8ff; - --color-prettylights-syntax-brackethighlighter-angle: #8b949e; - --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58; - --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; - --color-btn-text: rgb(250, 250, 249); - --color-btn-bg: rgb(41, 37, 36); - --color-btn-border: rgb(240 246 252 / 10%); - --color-btn-shadow: 0 0 transparent; - --color-btn-inset-shadow: 0 0 transparent; - --color-btn-hover-bg: rgb(41, 37, 36); - --color-btn-hover-border: transparent; - --color-btn-active-bg: rgb(41, 37, 36); - --color-btn-active-border: transparent; - --color-btn-selected-bg: rgb(41, 37, 36); - --color-btn-primary-text: rgb(250, 250, 249); - --color-btn-primary-bg: rgba(255, 79, 0, 0.9); - --color-btn-primary-hover-bg: rgb(255, 79, 0); - --color-btn-primary-border: transparent; - --color-btn-primary-shadow: 0 0 transparent; - --color-btn-primary-inset-shadow: 0 0 transparent; - --color-btn-primary-hover-border: transparent; - --color-btn-primary-selected-bg: rgba(255, 79, 0, 0.998); - --color-btn-primary-selected-shadow: 0 0 transparent; - --color-btn-primary-disabled-text: #868181; - --color-btn-primary-disabled-bg: #892f07; - --color-btn-primary-disabled-border: transparent; - --color-action-list-item-default-hover-bg: rgb(177 186 196 / 12%); - --color-segmented-control-bg: rgb(18, 15, 15); - --color-segmented-control-button-bg: rgb(18, 15, 15); - --color-segmented-control-button-selected-border: transparent; - --color-fg-default: rgb(250, 250, 249); - --color-fg-muted: rgb(168, 162, 158); - --color-fg-subtle: rgb(168, 162, 158); - --color-canvas-default: rgb(18, 15, 15); - --color-canvas-overlay: rgb(18, 15, 15); - --color-canvas-inset: rgb(12, 10, 9); - --color-canvas-subtle: rgb(18, 15, 15); - --color-border-default: rgb(255 255 255 / 0.1); - --color-border-muted: rgb(255 255 255 / 0.1); - --color-neutral-muted: rgb(110 118 129 / 40%); - --color-accent-fg: rgb(255, 79, 0); - --color-accent-emphasis: rgb(255, 79, 0); - --color-accent-muted: rgb(255, 79, 0); - --color-accent-subtle: rgb(255, 79, 0); - --color-success-fg: #3fb950; - --color-attention-fg: #d29922; - --color-attention-muted: rgb(187 128 9 / 40%); - --color-attention-subtle: rgb(187 128 9 / 15%); - --color-danger-fg: #f85149; - --color-danger-muted: rgb(248 81 73 / 40%); - --color-danger-subtle: rgb(248 81 73 / 10%); - --color-primer-shadow-inset: 0 0 transparent; - --color-scale-gray-7: #21262d; - --color-scale-blue-8: #0c2d6b; - - /*! Extensions from @primer/css/alerts/flash.scss */ - - --color-social-reaction-bg-hover: var(--color-scale-gray-7); - --color-social-reaction-bg-reacted-hover: rgb(41, 37, 36); -} - -main .pagination-loader-container { - background-image: url("https://github.com/images/modules/pulls/progressive-disclosure-line-dark.svg"); -} - -main .gsc-loading-image { - background-image: url("https://github.githubassets.com/images/mona-loading-dark.gif"); -} - -main .gsc-reactions-menu[open] .gsc-reactions-popover p { - color: var(--color-fg-default); -} - -main .gsc-comment-box-write textarea::placeholder { - color: var(--color-fg-muted); -} - -main .gsc-comment-box-textarea:focus { - background-color: var(--color-canvas-inset); -} - -main .gsc-social-reaction-summary-item-count { - color: var(--color-fg-default); -} - -main .gsc-comment-author-avatar, -main .gsc-comment-author-avatar .link-primary:hover { - color: var(--color-fg-default); -} - -main .gsc-right-header .BtnGroup-item.BtnGroup-item--selected { - background-color: rgb(41, 37, 36); -} - -main .gsc-right-header .BtnGroup-item .btn:hover { - background-color: rgb(41, 37, 36); -} - -main .gsc-comment .color-box-border-info { - border-color: var(--color-border-default); -} - -main .gsc-social-reaction-summary-item { - background-color: rgb(18, 15, 15); - border: transparent; -} -main .gsc-social-reaction-summary-item.has-reacted { - background-color: rgb(41, 37, 36); - border: transparent; -} - -main .gsc-emoji-button.has-reacted { - background-color: rgb(41, 37, 36); - border: transparent; -} - -main .gsc-emoji-button { - border-radius: 16px; -} diff --git a/website/src/components/Comments.tsx b/website/src/components/Comments.tsx deleted file mode 100644 index da63e1b8e7..0000000000 --- a/website/src/components/Comments.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import Giscus from "@giscus/react"; -import { cn } from "@rivet-gg/components"; - -interface CommentsProps { - className?: string; -} - -export function Comments({ className }: CommentsProps) { - return ( -
- -
- ); -} diff --git a/website/src/components/TabsScript.astro b/website/src/components/TabsScript.astro index ffef754f60..caba0daa1f 100644 --- a/website/src/components/TabsScript.astro +++ b/website/src/components/TabsScript.astro @@ -50,64 +50,7 @@ function initializeCodeGroups() { const codeGroupContainers = document.querySelectorAll('[data-code-group-container]'); codeGroupContainers.forEach((container) => { - const isWorkspace = container.hasAttribute('data-code-group-workspace'); - - if (isWorkspace) { - initializeWorkspaceCodeGroup(container); - } else { - initializeTabCodeGroup(container); - } - }); - } - - function initializeTabCodeGroup(container: Element) { - const tabsList = container.querySelector('[data-code-group-tabs]'); - const contentContainer = container.querySelector('[data-code-group-content-container]'); - const source = container.querySelector('[data-code-group-source]'); - - if (!tabsList || !contentContainer || !source) return; - - // Already initialized - if (tabsList.children.length > 0) return; - - // Find all code blocks in source (they have data-code-block attribute) - const codeBlocks = source.querySelectorAll('[data-code-block]'); - let visibleIndex = 0; - codeBlocks.forEach((block, index) => { - const title = block.getAttribute('data-code-title') || 'Code'; - const id = block.getAttribute('data-code-id') || `code-${index}`; - const isHidden = block.getAttribute('data-code-hide') === 'true'; - - // Skip hidden blocks (don't create tabs for them, don't add to DOM) - if (isHidden) { - return; - } - - // Create tab button - const button = document.createElement('button'); - button.type = 'button'; - button.setAttribute('data-code-group-trigger', id); - button.className = [ - 'relative inline-flex min-h-[2.75rem] items-center justify-center whitespace-nowrap', - 'rounded-none border-b-2 bg-transparent px-4 py-2.5 text-sm font-semibold', - 'ring-offset-background transition-none', - 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', - 'disabled:pointer-events-none disabled:opacity-50', - visibleIndex === 0 ? 'border-b-primary text-white' : 'border-b-transparent text-muted-foreground' - ].join(' '); - button.textContent = title; - tabsList.appendChild(button); - - // Create content wrapper and move code block - const clonedBlock = block.cloneNode(true) as HTMLElement; - - const contentWrapper = document.createElement('div'); - contentWrapper.setAttribute('data-code-group-content', id); - contentWrapper.className = visibleIndex === 0 ? '' : 'hidden'; - contentWrapper.appendChild(clonedBlock); - contentContainer.appendChild(contentWrapper); - - visibleIndex++; + initializeWorkspaceCodeGroup(container); }); } @@ -169,6 +112,63 @@ } + // Responsive layout: switch CodeGroup from sidebar to top tabs when narrow + const CODE_GROUP_NARROW_THRESHOLD = 500; + + function setupCodeGroupResponsiveLayout() { + const containers = document.querySelectorAll('[data-code-group-container]'); + + containers.forEach((container) => { + const body = container.querySelector('[data-code-group-body]') as HTMLElement; + const sidebar = container.querySelector('[data-code-group-sidebar]') as HTMLElement; + + if (!body || !sidebar) return; + + // Skip if already observed + if (container.hasAttribute('data-code-group-observed')) return; + container.setAttribute('data-code-group-observed', 'true'); + + let currentLayout: string | null = null; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = entry.contentRect.width; + const newLayout = width < CODE_GROUP_NARROW_THRESHOLD ? 'tabs' : 'sidebar'; + + if (newLayout === currentLayout) continue; + currentLayout = newLayout; + + const isNarrow = newLayout === 'tabs'; + + // Toggle body layout direction + body.classList.toggle('flex-col', isNarrow); + + // Toggle sidebar between vertical and horizontal + sidebar.classList.toggle('flex-col', !isNarrow); + sidebar.classList.toggle('w-[160px]', !isNarrow); + sidebar.classList.toggle('py-2', !isNarrow); + sidebar.classList.toggle('overflow-y-auto', !isNarrow); + + sidebar.classList.toggle('flex-row', isNarrow); + sidebar.classList.toggle('px-2', isNarrow); + sidebar.classList.toggle('py-1.5', isNarrow); + sidebar.classList.toggle('overflow-x-auto', isNarrow); + sidebar.classList.toggle('gap-1', isNarrow); + + // Toggle trigger item classes + sidebar.querySelectorAll('[data-code-group-trigger]').forEach((trigger) => { + trigger.classList.toggle('w-full', !isNarrow); + trigger.classList.toggle('text-left', !isNarrow); + trigger.classList.toggle('whitespace-nowrap', isNarrow); + trigger.classList.toggle('shrink-0', isNarrow); + }); + } + }); + + observer.observe(container); + }); + } + const CODE_COLLAPSE_HEIGHT = 500; function initializeCollapsibleCodeBlocks() { @@ -209,10 +209,12 @@ // Run on page load and after view transitions initializeTabs(); initializeCodeGroups(); + setupCodeGroupResponsiveLayout(); initializeCollapsibleCodeBlocks(); document.addEventListener('astro:after-swap', () => { initializeTabs(); initializeCodeGroups(); + setupCodeGroupResponsiveLayout(); initializeCollapsibleCodeBlocks(); }); @@ -270,30 +272,16 @@ const codeGroupContainer = codeGroupTrigger.closest('[data-code-group-container]'); if (!codeGroupContainer) return; - const isWorkspace = codeGroupContainer.hasAttribute('data-code-group-workspace'); - - // Update trigger styles + // Update trigger styles (sidebar style) const triggers = codeGroupContainer.querySelectorAll('[data-code-group-trigger]'); triggers.forEach((trigger) => { const isActive = trigger.getAttribute('data-code-group-trigger') === triggerId; - if (isWorkspace) { - // Sidebar style - if (isActive) { - trigger.classList.add('text-white', 'bg-white/10'); - trigger.classList.remove('text-zinc-500', 'hover:text-zinc-300', 'hover:bg-white/5'); - } else { - trigger.classList.remove('text-white', 'bg-white/10'); - trigger.classList.add('text-zinc-500', 'hover:text-zinc-300', 'hover:bg-white/5'); - } + if (isActive) { + trigger.classList.add('text-white', 'bg-white/10'); + trigger.classList.remove('text-zinc-500', 'hover:text-zinc-300', 'hover:bg-white/5'); } else { - // Tab style (underline) - if (isActive) { - trigger.classList.add('border-b-primary', 'text-white'); - trigger.classList.remove('border-b-transparent', 'text-muted-foreground'); - } else { - trigger.classList.remove('border-b-primary', 'text-white'); - trigger.classList.add('border-b-transparent', 'text-muted-foreground'); - } + trigger.classList.remove('text-white', 'bg-white/10'); + trigger.classList.add('text-zinc-500', 'hover:text-zinc-300', 'hover:bg-white/5'); } }); diff --git a/website/src/components/v2/Code.tsx b/website/src/components/v2/Code.tsx index 84a0c81fb1..4d65f0c549 100644 --- a/website/src/components/v2/Code.tsx +++ b/website/src/components/v2/Code.tsx @@ -75,51 +75,27 @@ const languageIcons: Record = { interface CodeGroupProps { className?: string; children: ReactNode; + /** Marks this code group as a workspace for type-checking multi-file examples. */ workspace?: boolean; } -export function CodeGroup({ children, className, workspace }: CodeGroupProps) { - if (workspace) { - return ( -
-
-
- {/* File tree items populated by TabsScript.astro */} -
-
- {/* Content is moved here by TabsScript.astro */} -
-
-
- {children} -
-
- ); - } - - // Use Tabs-like pattern: render container with hidden source, let TabsScript create tabs +export function CodeGroup({ children, className }: CodeGroupProps) { return (
-
+
- {/* Tabs are populated by TabsScript.astro from data-code-group-source */} + {/* File tree items populated by TabsScript.astro */} +
+
+ {/* Content is moved here by TabsScript.astro */}
-
-
- {/* Content is moved here by TabsScript.astro */}
{children} diff --git a/website/src/content/posts/2026-02-24-introducing-rivet-workflows/page.mdx b/website/src/content/posts/2026-02-24-introducing-rivet-workflows/page.mdx new file mode 100644 index 0000000000..6449626c83 --- /dev/null +++ b/website/src/content/posts/2026-02-24-introducing-rivet-workflows/page.mdx @@ -0,0 +1,510 @@ +--- +author: nathan-flurry +published: "2026-02-24" +category: changelog +keywords: ["workflows", "durable-execution", "actors", "typescript", "open-source"] +title: "Introducing Rivet Workflows" +description: "Durable, replayable workflows for TypeScript. Sleep, join, race, retry, rollback, and human-in-the-loop with realtime frontend integration." +--- + +Today we're releasing **Rivet Workflows**: a durable execution engine for TypeScript built in to [Rivet Actors](/docs/actors). + +- **Durable & resilient**: Progress persists across crashes, deploys, and restarts. Failed steps retry automatically. +- **Advanced control flow**: Sleep, join, race, rollback, human-in-the-loop, and durable loops +- **Durable agents**: Build AI agents with tool use, human-in-the-loop, and automatic checkpointing using the AI SDK +- **React integration**: Stream workflow progress to your frontend in realtime with `useActor` +- **Observable**: Built-in workflow inspector for debugging every run +- **Permissive open-source**: Apache 2.0, runs anywhere: Node.js, Bun, Cloudflare Workers + +## Show Me The Code + +Wrap any multi-step process with `workflow()` and each step is checkpointed automatically. Crashes, deploys, and restarts pick up where they left off. + + +```ts Basic +import { actor } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; + +const checkout = actor({ + state: { + orderId: null as string | null, + charged: false, + shipped: false, + }, + run: workflow(async (ctx) => { + await ctx.step("create-order", async () => { + ctx.state.orderId = crypto.randomUUID(); + }); + + await ctx.step("charge-payment", async () => { + ctx.state.charged = true; + }); + + await ctx.step("ship-order", async () => { + ctx.state.shipped = true; + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Human-in-the-Loop +// Pause indefinitely for approval, then continue where you left off +import { actor } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +type ApprovalMessage = { requestId: string; approved: boolean }; + +const approvalGate = actor({ + state: { + pendingRequestId: null as string | null, + decision: null as "approved" | "rejected" | null, + }, + run: workflow(async (ctx) => { + await ctx.step("request-approval", async () => { + ctx.state.pendingRequestId = "req-1"; + ctx.state.decision = null; + }); + + // Pauses here until a message arrives. Could be minutes or days. + await ctx.loop({ + name: "approval-loop", + run: async (loopCtx) => { + const [message] = await loopCtx.queue.next("wait-approval"); + + if (!message) return Loop.continue(undefined); + const approval = message.body as ApprovalMessage; + + await loopCtx.step("apply-decision", async () => { + loopCtx.state.decision = approval.approved ? "approved" : "rejected"; + }); + + return Loop.break(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Loop +// Use durable loops for long-lived workflows that process messages indefinitely +import { actor, queue } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +const worker = actor({ + state: { + processed: 0, + total: 0, + }, + queues: { + orders: queue<{ amount: number }>(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "order-loop", + run: async (loopCtx) => { + const [message] = await loopCtx.queue.next("wait-order", { + names: ["orders"], + }); + + if (!message) return Loop.continue(undefined); + + await loopCtx.step("process-order", async () => { + loopCtx.state.total += message.body.amount; + loopCtx.state.processed += 1; + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Sleep +// Sleep for arbitrary durations without consuming compute +import { actor, queue } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +type Reminder = { text: string; at: number }; + +const reminderActor = actor({ + state: { fired: [] as string[] }, + queues: { + reminders: queue(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "reminder-loop", + run: async (loopCtx) => { + const [message] = await loopCtx.queue.next("wait-reminder", { + names: ["reminders"], + }); + + if (!message) return Loop.continue(undefined); + + // Sleep until the scheduled time, even if it's days away + await loopCtx.sleepUntil("wait-until-reminder", message.body.at); + + await loopCtx.step("fire-reminder", async () => { + loopCtx.state.fired.push(message.body.text); + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Join +// Run independent work branches in parallel and collect results +import { actor, queue } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +const dashboard = actor({ + state: { + summary: null as null | { users: number; orders: number; revenue: number }, + }, + queues: { + refresh: queue>(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "dashboard-loop", + run: async (loopCtx) => { + await loopCtx.queue.next("wait-refresh", { names: ["refresh"] }); + + const summary = await loopCtx.join("fetch-summary", { + users: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-users", async () => 42); + }, + }, + orders: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-orders", async () => 12); + }, + }, + revenue: { + run: async (branchCtx) => { + return await branchCtx.step("fetch-revenue", async () => 9_900); + }, + }, + }); + + await loopCtx.step("save-summary", async () => { + loopCtx.state.summary = summary; + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Race +// Use race when you need timeout behavior or want the fastest result +import { actor, queue } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +const raceActor = actor({ + state: { + lastWinner: null as null | string, + lastValue: null as null | string, + }, + queues: { + start: queue>(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "race-loop", + run: async (loopCtx) => { + await loopCtx.queue.next("wait-start", { names: ["start"] }); + + const { winner, value } = await loopCtx.race("work-vs-timeout", [ + { + name: "work", + run: async (branchCtx) => { + await branchCtx.sleep("work-delay", 75); + return await branchCtx.step("finish-work", async () => "done"); + }, + }, + { + name: "timeout", + run: async (branchCtx) => { + await branchCtx.sleep("timeout-delay", 5_000); + return "timed-out"; + }, + }, + ]); + + await loopCtx.step("record-result", async () => { + loopCtx.state.lastWinner = winner; + loopCtx.state.lastValue = value; + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +```ts Rollback +// Compensating actions run automatically when a later step fails +import { actor, queue } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +async function reserveInventory(orderId: string): Promise { + return `reservation-${orderId}`; +} +async function releaseInventory(_id: string): Promise {} +async function chargeCard(orderId: string): Promise { + return `charge-${orderId}`; +} +async function refundCharge(_id: string): Promise {} + +const checkoutSaga = actor({ + state: { + lastOrderId: null as string | null, + }, + queues: { + checkout: queue<{ orderId: string }>(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "checkout-loop", + run: async (loopCtx) => { + const [message] = await loopCtx.queue.next("wait-checkout", { + names: ["checkout"], + }); + + if (!message) return Loop.continue(undefined); + + await loopCtx.rollbackCheckpoint("checkout-checkpoint"); + + const reservationId = await loopCtx.step({ + name: "reserve-inventory", + run: async () => await reserveInventory(message.body.orderId), + rollback: async (_rollbackCtx, output) => { + await releaseInventory(output as string); + }, + }); + + const chargeId = await loopCtx.step({ + name: "charge-card", + run: async () => await chargeCard(message.body.orderId), + rollback: async (_rollbackCtx, output) => { + await refundCharge(output as string); + }, + }); + + await loopCtx.step("mark-complete", async () => { + loopCtx.state.lastOrderId = message.body.orderId; + void reservationId; + void chargeId; + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + + +## Example: Durable Agents + +Combine workflows with the [AI SDK](https://ai-sdk.dev) to build AI agents that survive crashes and pick up exactly where they left off. Each tool call is checkpointed, and human-in-the-loop approval pauses the workflow until a response arrives. + +```ts +import { actor } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; +import { generateText, tool } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +const agent = actor({ + state: { + response: null as string | null, + }, + run: workflow(async (ctx) => { + const result = await ctx.step("generate", async () => { + return await generateText({ + model: anthropic("claude-sonnet-4-20250514"), + tools: { + getWeather: tool({ + description: "Get the weather for a location", + parameters: z.object({ location: z.string() }), + execute: async ({ location }) => `72°F in ${location}`, + }), + }, + maxSteps: 5, + prompt: "What's the weather in San Francisco?", + }); + }); + + await ctx.step("save", async () => { + ctx.state.response = result.text; + }); + }), + actions: { + getState: (c) => c.state, + }, +}); +``` + +## React Integration + +Broadcast workflow progress to your frontend in realtime. Store progress in actor state, broadcast on every step, and render it with `useActor`. + + +```tsx App.tsx +import { createRivetKit } from "@rivetkit/react"; +import type { registry } from "./actors"; + +const { useActor } = createRivetKit(); + +function WorkflowProgress() { + const { connection, connStatus } = useActor({ + name: "progressActor", + key: ["main"], + }); + const [progress, setProgress] = useState({ stage: "idle", completed: 0, total: 0 }); + + useEffect(() => { + if (!connection) return; + connection.on("progressUpdated", (p: typeof progress) => setProgress(p)); + }, [connection]); + + if (connStatus !== "connected") return
Connecting...
; + + return ( +
+

Stage: {progress.stage}

+

Progress: {progress.completed} / {progress.total}

+
+ ); +} +``` + +```ts actors.ts +import { actor, event, queue, setup } from "rivetkit"; +import { Loop, workflow } from "rivetkit/workflow"; + +type Progress = { + stage: "idle" | "running" | "completed"; + completed: number; + total: number; +}; + +export const progressActor = actor({ + state: { + progress: { stage: "idle", completed: 0, total: 0 } as Progress, + }, + events: { + progressUpdated: event(), + }, + queues: { + jobs: queue<{ value: number }>(), + }, + run: workflow(async (ctx) => { + await ctx.loop({ + name: "progress-loop", + run: async (loopCtx) => { + const [message] = await loopCtx.queue.next("wait-job", { + names: ["jobs"], + }); + + if (!message) return Loop.continue(undefined); + + await loopCtx.step("mark-running", async () => { + loopCtx.state.progress = { + stage: "running", + completed: loopCtx.state.progress.completed, + total: loopCtx.state.progress.total + 1, + }; + loopCtx.broadcast("progressUpdated", loopCtx.state.progress); + }); + + await loopCtx.step("complete-job", async () => { + loopCtx.state.progress = { + stage: "completed", + completed: loopCtx.state.progress.completed + 1, + total: loopCtx.state.progress.total, + }; + loopCtx.broadcast("progressUpdated", loopCtx.state.progress); + }); + + return Loop.continue(undefined); + }, + }); + }), + actions: { + getState: (c) => c.state, + }, +}); + +export const registry = setup({ use: { progressActor } }); +``` +
+ +No pub/sub service, no polling, no separate WebSocket server. The actor broadcasts events directly to connected clients. The React hook handles connection lifecycle automatically. + +## Inspector + +TODO + +## Rivet Actors at Its Core + +Rivet Workflows is built directly in to [Rivet Actors](/docs/actors), a lightweight primitive for stateful workloads. Actors already provide persistent state, queues, and fault tolerance. Workflows add durable replay on top, so you get all of the Rivet Actor primitives for free: + +- **State with zero latency**: No database round trips. Workflow state lives on the same machine as your compute. +- **Realtime over WebSockets**: Broadcast workflow progress to clients with `c.broadcast()`. +- **SQLite per actor**: Structured queries alongside workflow state. +- **Scales to zero**: Actors hibernate when idle. A workflow sleeping for a week costs nothing. +- **Runs anywhere**: Node.js, Bun, Cloudflare Workers, Vercel, or your own infrastructure. + +## Permissive Open-Source License + +Rivet Workflows is licensed under **Apache 2.0**. Use it in production, self-host it, embed it in commercial products. No restrictions. + +Other durable execution engines like Inngest (SSPL) and Restate (BSL 1.1) **use restrictive licenses** that limit self-hosting and commercial use. We believe durable execution should be infrastructure you own, not a dependency you rent. + +## Get Started + +Rivet Workflows is available today in RivetKit. + +```bash +npm install rivetkit +``` + +```ts +import { workflow } from "rivetkit/workflow"; +``` + +- [Workflows documentation](/docs/actors/workflows) +- [GitHub](https://github.com/rivet-dev/rivet) +- [Discord](https://rivet.dev/discord) diff --git a/website/src/integrations/typecheck-code-blocks.ts b/website/src/integrations/typecheck-code-blocks.ts index 3fd47ecbaf..b0ad844433 100644 --- a/website/src/integrations/typecheck-code-blocks.ts +++ b/website/src/integrations/typecheck-code-blocks.ts @@ -339,6 +339,12 @@ export function typecheckCodeBlocks(): AstroIntegration { name: "typecheck-code-blocks", hooks: { "astro:config:setup": async ({ logger }) => { + // SKIP_TYPECHECK_CODE_BLOCKS should never be enabled in production. This is strictly for local testing. + if (process.env.SKIP_TYPECHECK_CODE_BLOCKS) { + logger.info("Skipping code block type checking (SKIP_TYPECHECK_CODE_BLOCKS is set)"); + return; + } + logger.info("Type checking documentation code blocks..."); // Clean and create snippets directory @@ -474,6 +480,7 @@ export function typecheckCodeBlocks(): AstroIntegration { for (const err of errors) { console.error(err); } + logger.info("To skip type checking during local development, set SKIP_TYPECHECK_CODE_BLOCKS=1"); throw new Error( `Type checking failed with ${errors.length} error(s) in documentation code blocks` ); diff --git a/website/src/lib/article.tsx b/website/src/lib/article.tsx index 72467352c4..2db16afd7b 100644 --- a/website/src/lib/article.tsx +++ b/website/src/lib/article.tsx @@ -10,7 +10,6 @@ export const AUTHORS = { socials: { twitter: "https://x.com/NathanFlurry/", github: "https://github.com/nathanflurry", - bluesky: "https://bsky.app/profile/nathanflurry.com", }, }, "nicholas-kissel": { diff --git a/website/src/pages/blog/[...slug].astro b/website/src/pages/blog/[...slug].astro index b97f1bf46d..e83e7c034e 100644 --- a/website/src/pages/blog/[...slug].astro +++ b/website/src/pages/blog/[...slug].astro @@ -3,14 +3,12 @@ import { getCollection, render } from 'astro:content'; import { Image } from 'astro:assets'; import BlogLayout from '@/layouts/BlogLayout.astro'; import { ArticleSocials } from '@/components/ArticleSocials'; -import { Comments } from '@/components/Comments'; import { DocsTableOfContents } from '@/components/DocsTableOfContents'; import { Prose } from '@/components/Prose'; import { formatTimestamp } from '@/lib/formatDate'; import { AUTHORS, CATEGORIES } from '@/lib/article'; import { AuthorAvatar } from '@/components/AuthorAvatar'; import { Icon, faBluesky, faCalendarDay, faChevronRight, faGithub, faXTwitter } from '@rivet-gg/icons'; -import clsx from 'clsx'; import * as mdxComponents from '@/components/mdx'; type AuthorInfo = { @@ -64,8 +62,6 @@ const tableOfContents = headings return acc; }, [] as any[]); -const isTechnical = category.name === 'Technical' || category.name === 'Guide'; - // Load other articles const allPosts = await getCollection('posts'); const filteredOtherPosts = allPosts @@ -110,9 +106,7 @@ const breadcrumbSchema = { >