diff --git a/.claude/reference/feature-flags.md b/.claude/reference/feature-flags.md
index 797049e70a..12eff39b4d 100644
--- a/.claude/reference/feature-flags.md
+++ b/.claude/reference/feature-flags.md
@@ -35,6 +35,7 @@ if (features.platform) {
| `datacenter` | Datacenter-related UI. |
| `danger-zone` | Destructive settings actions (`features.dangerZone`). |
| `byoc` | Bring Your Own Cloud (`features.byoc`): the BYOC option in the create-project flow, BYOC clusters in the project lists, and the `/orgs/$org/clusters/$cluster` page. Requires `platform`. |
+| `services` | Managed services (`features.services`): the Services section of the onboarding product picker, the Durable Streams onboarding path, and the Services tab on the namespace settings drawer. Independent of `platform` (cloud hands out a managed service URL, self-host runs the worker container). |
Deployment flavors map to flag sets roughly as: **cloud** = all on; **OSS** = `auth`/`platform`/`acl` off; **enterprise** = `acl` on, `auth`/`platform` off (engine enforces auth without a login UI). Do not treat `platform`/`auth` as "engine requires credentials" — that is `acl`. **`compute` is opt-in even on cloud** — each Railway service adds it to `VITE_FEATURE_FLAGS` per-environment (e.g. staging on, prod off) rather than inheriting the cloud default-on set.
@@ -69,7 +70,7 @@ localStorage.setItem("FEATURE_FLAGS", ""); location.reload();
// Full cloud: all flags on (see the commented canonical list in frontend/.env.local)
localStorage.setItem(
"FEATURE_FLAGS",
- "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,byoc",
+ "compute,platform,acl,auth,captcha,branding,support,billing,datacenter,danger-zone,multitenancy,byoc,services",
);
location.reload();
diff --git a/frontend/public/images/brand/durable-streams-mark.svg b/frontend/public/images/brand/durable-streams-mark.svg
new file mode 100644
index 0000000000..5655143b95
--- /dev/null
+++ b/frontend/public/images/brand/durable-streams-mark.svg
@@ -0,0 +1,18 @@
+
diff --git a/frontend/src/app/actors-grid.tsx b/frontend/src/app/actors-grid.tsx
index a0ab9a8d3d..4a8fd2a4dd 100644
--- a/frontend/src/app/actors-grid.tsx
+++ b/frontend/src/app/actors-grid.tsx
@@ -206,7 +206,7 @@ export function ActorsGrid({ namespaceLabel }: { namespaceLabel?: string }) {
- Deploy code that registers an actor to
- see it here.
+ {features.services
+ ? "Deploy code that registers an actor or add a service to see it here."
+ : "Deploy code that registers an actor to see it here."}
diff --git a/frontend/src/app/compute-deploy.tsx b/frontend/src/app/compute-deploy.tsx
index fd356b74d0..1871f996ab 100644
--- a/frontend/src/app/compute-deploy.tsx
+++ b/frontend/src/app/compute-deploy.tsx
@@ -23,6 +23,7 @@ import { type HostedTarget, hostedUrl } from "@/components/mcp/scope";
import {
getAgentInstructionsPrompt,
getComputeAddendum,
+ getDurableStreamsServiceUrl,
type McpSetup,
type OnboardingTarget,
} from "@/content/agent-prompts";
@@ -82,6 +83,7 @@ export function useAgentInstructionsCode({
const secretToken = useRivetDsn({ kind: "secret", endpoint });
const mcp = useMcpSetup();
const namespace = useEngineCompatDataProvider().engineNamespace;
+ const durableStreamsServiceUrl = useDurableStreamsServiceUrl();
return getAgentInstructionsPrompt({
providerStr,
@@ -96,9 +98,19 @@ export function useAgentInstructionsCode({
cliDeploy: provider === "rivet",
target,
mcp,
+ durableStreamsServiceUrl,
});
}
+// Where the managed Durable Streams service for this namespace is served on
+// Rivet Cloud. Self-hosted flavors run the worker themselves, so there is no
+// fixed URL to hand out.
+export function useDurableStreamsServiceUrl(): string | undefined {
+ const namespace = useEngineCompatDataProvider().engineNamespace;
+ if (!features.compute || !features.services) return undefined;
+ return getDurableStreamsServiceUrl(getRivetRunUrl(namespace));
+}
+
// The MCP setup the copy-prompt should instruct the agent to perform. The hosted
// connection needs the user to approve an OAuth window, so the agent has to hand
// that step back; the local stdio server it can wire up itself.
diff --git a/frontend/src/app/engine-namespace-landing.tsx b/frontend/src/app/engine-namespace-landing.tsx
index 1054419f7a..298cba845f 100644
--- a/frontend/src/app/engine-namespace-landing.tsx
+++ b/frontend/src/app/engine-namespace-landing.tsx
@@ -5,8 +5,9 @@ import { Button, H1, ScrollArea, SmallText, WithTooltip } from "@/components";
import { useEngineNamespaceDataProvider } from "@/components/actors";
import { NoProvidersAlert } from "@/components/actors/no-providers-alert";
import { VisibilitySensor } from "@/components/visibility-sensor";
-import { AddComponentButton, AddComponentCard } from "./add-component-card";
+import { features } from "@/lib/features";
import { ActorBuildCard, ActorGridCardSkeleton } from "./actors-grid";
+import { AddComponentButton, AddComponentCard } from "./add-component-card";
// Engine (OSS / enterprise) namespace landing shown when no Actor name is
// selected. This is the engine counterpart to the cloud `ActorsGrid`; keep the
@@ -79,7 +80,7 @@ export function EngineNamespaceLanding() {
- Deploy code that registers an actor to
- see it here.
+ {features.services
+ ? "Deploy code that registers an actor or add a service to see it here."
+ : "Deploy code that registers an actor to see it here."}
diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx
index 2327f4a39a..baa66e515d 100644
--- a/frontend/src/app/getting-started.tsx
+++ b/frontend/src/app/getting-started.tsx
@@ -26,20 +26,38 @@ import {
CodeGroup,
CodeGroupSyncProvider,
CodePreview,
+ DiscreteCopyButton,
FormField,
} from "@/components";
import {
useCloudNamespaceDataProvider,
useEngineCompatDataProvider,
} from "@/components/actors";
+import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step";
+import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup";
+import {
+ DEFAULT_AGENT,
+ DEFAULT_PACKAGES,
+ DEFAULT_SANDBOX_PROVIDER,
+} from "@/components/onboarding/agent-os/catalog";
+import { ProductPicker } from "@/components/products/product-picker";
import { defineStepper } from "@/components/ui/stepper";
import {
+ DURABLE_STREAMS_CLIENT_PACKAGE,
+ DURABLE_STREAMS_DEV_COMMAND,
+ DURABLE_STREAMS_DOCS_URL,
+ DURABLE_STREAMS_LOCAL_URL,
+ getDurableStreamsClientSnippet,
+ getDurableStreamUrl,
getOnboardingTargetCopy,
type OnboardingTarget,
} from "@/content/agent-prompts";
+import {
+ MANAGED_SERVICES_POOL_CONFIG,
+ useEnableManagedServicesMutation,
+} from "@/app/managed-services";
import { deriveProviderFromMetadata } from "@/lib/data";
import { engineEnv } from "@/lib/env";
-import { ProductPicker } from "@/components/products/product-picker";
import { features } from "@/lib/features";
import { queryClient } from "@/queries/global";
import { cn } from "../components/lib/utils";
@@ -52,6 +70,14 @@ import {
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { TEST_IDS } from "../utils/test-ids";
+import {
+ AgentPromptBanner,
+ CommandBox,
+ defaultRuntimeModeForProvider,
+ useAgentInstructionsCode,
+ useComputeInstructionsCode,
+ useDurableStreamsServiceUrl,
+} from "./compute-deploy";
import { DeploymentCheck } from "./deployment-check";
import { useEndpoint } from "./dialogs/connect-manual-serverful-frame";
import {
@@ -59,7 +85,7 @@ import {
Configuration,
ConfigurationAccordion,
} from "./dialogs/connect-manual-serverless-frame";
-import { EnvVariables } from "./env-variables";
+import { EnvVariables, useRivetDsn } from "./env-variables";
import {
StepperForm,
StepVisibilityContext,
@@ -67,20 +93,6 @@ import {
} from "./forms/stepper-form";
import { Content } from "./layout";
import { StepContentSkeleton } from "./onboarding-skeleton";
-import { AgentSelectStep } from "@/components/onboarding/agent-os/agent-select-step";
-import { buildAgentOsSetup } from "@/components/onboarding/agent-os/build-agent-os-setup";
-import {
- DEFAULT_AGENT,
- DEFAULT_PACKAGES,
- DEFAULT_SANDBOX_PROVIDER,
-} from "@/components/onboarding/agent-os/catalog";
-import {
- AgentPromptBanner,
- CommandBox,
- defaultRuntimeModeForProvider,
- useAgentInstructionsCode,
- useComputeInstructionsCode,
-} from "./compute-deploy";
function platformTitle(provider: unknown): string {
return (
@@ -100,7 +112,13 @@ const stepper = defineStepper(
// it must survive navigation past this step.
schema: z.object({
template: z
- .enum(["actor", "agent-os", "workflows", "dynamic-apps"])
+ .enum([
+ "actor",
+ "agent-os",
+ "workflows",
+ "dynamic-apps",
+ "durable-streams",
+ ])
.optional(),
}),
group: "local",
@@ -140,6 +158,18 @@ const stepper = defineStepper(
isVisible: (values: Record) =>
values.template === "agent-os",
},
+ // Services are managed by Rivet, so there is no runner or image to deploy.
+ // Durable Streams gets this step in place of the platform deploy below.
+ {
+ id: "services",
+ title: "Connect to Durable Streams",
+ next: "Done",
+ previous: "Back",
+ schema: z.object({}),
+ group: "deploy",
+ isVisible: (values: Record) =>
+ features.services && values.template === "durable-streams",
+ },
{
id: "deploy",
title: "Deploy",
@@ -149,6 +179,8 @@ const stepper = defineStepper(
previous: "Back",
assist: true,
group: "deploy",
+ isVisible: (values: Record) =>
+ values.template !== "durable-streams",
schema: (values: Record) => {
const provider = (values.provider as string) || "rivet";
if (provider === "rivet") {
@@ -362,6 +394,11 @@ export function GettingStarted({
),
+ services: () => (
+
+
+
+ ),
deploy: () => (
@@ -382,7 +419,18 @@ export function GettingStarted({
>;
const provider = (accumulated.provider ??
live.provider) as string | undefined;
- if (provider && provider !== "rivet") {
+ const template = (accumulated.template ??
+ live.template) as
+ | OnboardingTarget
+ | undefined;
+ // Services skip the platform deploy step, so
+ // the provider fields hold untouched defaults;
+ // writing them would register a bogus runner.
+ if (
+ provider &&
+ provider !== "rivet" &&
+ template !== "durable-streams"
+ ) {
await saveProviderConfig({
...accumulated,
provider,
@@ -734,7 +782,11 @@ function AgentOsKeyNotice() {
// Product selector shown atop the first step. Selecting a product is the whole
// step, so the choice advances the wizard instead of parking the user in front
// of a Continue button.
-function BuildTargetSelector() {
+function BuildTargetSelector({
+ onSelectTarget,
+}: {
+ onSelectTarget?: (target: OnboardingTarget) => void;
+}) {
const { control, setValue } = useFormContext();
const submitForm = useStepperFormSubmit();
return (
@@ -744,6 +796,7 @@ function BuildTargetSelector() {
render={() => (
{
+ onSelectTarget?.(template);
setValue("template", template, {
shouldDirty: true,
shouldTouch: true,
@@ -757,6 +810,22 @@ function BuildTargetSelector() {
);
}
+// Picking a service provisions the managed services pool up front so it is
+// warming while the user reads the connection steps.
+function CloudBuildTargetSelector() {
+ const { mutate } = useEnableManagedServicesMutation();
+ return (
+ {
+ if (target !== "durable-streams") {
+ return;
+ }
+ mutate(MANAGED_SERVICES_POOL_CONFIG);
+ }}
+ />
+ );
+}
+
function useOnboardingTarget(): OnboardingTarget {
return (
(useWatch({ name: "template" }) as OnboardingTarget | undefined) ??
@@ -765,12 +834,19 @@ function useOnboardingTarget(): OnboardingTarget {
}
function SelectProductStep() {
- return ;
+ return features.compute ? (
+
+ ) : (
+
+ );
}
function RunLocallyStep() {
const target = useOnboardingTarget();
const copy = getOnboardingTargetCopy(target);
+ if (target === "durable-streams") {
+ return ;
+ }
return (