Skip to main content
Quickstart

Effect.ts Quickstart (Beta)

Build a Rivet Actor with the Effect SDK

Effect support is in beta. The @rivetkit/effect API may change between releases. See the hello-world-effect and chat-room-effect examples for complete runnable projects.

Steps

Install Rivet

Add rivetkit, the Effect SDK, and its Effect peers:

npm install rivetkit @rivetkit/effect effect @effect/platform-node

Define Your Actor

Split each actor into a public contract and a server-only implementation so the contract can be imported from client code without leaking server details.

The contract declares the actor and its actions. Actions are standalone values with explicit effect/Schema payloads and successes, validated end to end:

import { Action, Actor } from "@rivetkit/effect";
import { Schema } from "effect";

export const Increment = Action.make("Increment", {
	payload: { amount: Schema.Number },
	success: Schema.Number,
});

export const GetCount = Action.make("GetCount", {
	success: Schema.Number,
});

export const Counter = Actor.make("Counter", {
	actions: [Increment, GetCount],
});

The implementation registers the actor with .toLayer. The wake function runs once when the actor awakes and returns the action handlers. Persisted state is accessed through a SubscriptionRef-like State API:

import { Actor, State } from "@rivetkit/effect";
import { Effect, Schema } from "effect";
import { Counter } from "./api.ts";

export const CounterLive = Counter.toLayer(
	Effect.fnUntraced(function* ({ rawRivetkitContext, state }) {
		return Counter.of({
			Increment: Effect.fnUntraced(function* ({ payload }) {
				const next = yield* State.updateAndGet(state, (current) => ({
					count: current.count + payload.amount,
				})).pipe(Effect.orDie);

				// Broadcast the new value to every connected client.
				rawRivetkitContext.broadcast("newCount", next.count);

				return next.count;
			}),
			GetCount: () =>
				State.get(state).pipe(
					Effect.map((current) => current.count),
					Effect.orDie,
				),
		});
	}),
	{
		state: {
			schema: Schema.Struct({ count: Schema.Number }),
			initialValue: () => ({ count: 0 }),
		},
		name: "Counter",
		icon: "calculator",
	},
);

Serve The Registry

Compose the actor layers and serve them with Registry.serve. Registry.layer() reads engine config from the environment, and the actor layer is provided a Client so actors can call other actors:

import { NodeRuntime } from "@effect/platform-node";
import { Client, Registry } from "@rivetkit/effect";
import { Layer } from "effect";
import { CounterLive } from "./actors/counter/live.ts";

const endpoint = process.env.RIVET_ENDPOINT ?? "http://127.0.0.1:6420";

const ActorsLayer = CounterLive.pipe(Layer.provide(Client.layer({ endpoint })));

const MainLayer = Registry.serve(ActorsLayer).pipe(Layer.provide(Registry.layer()));

// Keeps the layer alive. Tears down on SIGINT/SIGTERM.
Layer.launch(MainLayer).pipe(NodeRuntime.runMain);

Run The Server

Set RIVET_RUN_ENGINE=1 to spawn a local Rivet Engine alongside the server. The engine binary is downloaded and cached the first time you run, so there is nothing else to install:

RIVET_RUN_ENGINE=1 npx tsx --watch src/main.ts

Your server now connects to the Rivet Engine on http://localhost:6420. Clients connect directly to the engine on this port.

Visit http://localhost:6420 in your browser (or point your AI agent at it) to open the Rivet developer tools and inspect your actors live.

To point at a remote engine instead, set RIVET_ENDPOINT=https://... and omit RIVET_RUN_ENGINE.

Connect To The Rivet Actor

This code can run either in your frontend or within your backend:

Deploy

By default, Rivet stores actor state on the local file system.

To scale Rivet in production, follow a guide to deploy to your hosting provider of choice:

Feature Support

The Effect SDK wraps the most common actor features with typed, schema-validated APIs. Everything else is still fully usable through the raw RivetKit context (see Raw Escape Hatch below), so no feature is off limits, it just isn’t typed yet.

FeatureEffect-native APIAccess
Actor contract & actionsActor.make, Action.makeTyped
Persisted stateState.get / set / update / updateAndGet / changesTyped
Typed clientActor.client, Client.layerTyped
Typed errorsRivetErrorTyped
LoggingLoggerTyped
Sleep requestActor.SleepTyped
Actor address (actorId / name / key)Actor.CurrentAddressTyped
Registry serve / test / web handlerRegistryTyped
Events & broadcastNot yet wrappedrawRivetkitContext.broadcast(...)
ScheduleNot yet wrappedrawRivetkitContext.schedule.*
Embedded SQLiteNot yet wrappedrawRivetkitContext.db.execute(...)
DestroyNot yet wrappedrawRivetkitContext.destroy()
Queues, connections, vars, alarmsNot yet wrappedrawRivetkitContext.*
Lifecycle hooks (onSleep / onDestroy)Not yet wrappedrawRivetkitContext.*
Raw HTTP / WebSocket handlersNot yet wrappedrawRivetkitContext.*

Raw Escape Hatch

Every wake function receives rawRivetkitContext, the underlying RivetKit actor context. Reach for it to use any feature that does not have a typed wrapper yet. The typed state argument and the raw context point at the same actor, so you can mix both:

export const CounterLive = Counter.toLayer(
	Effect.fnUntraced(function* ({ rawRivetkitContext, state }) {
		return Counter.of({
			Increment: Effect.fnUntraced(function* ({ payload }) {
				// Typed state wrapper
				const next = yield* State.updateAndGet(state, (current) => ({
					count: current.count + payload.amount,
				})).pipe(Effect.orDie);

				// Untyped features run through the raw context
				rawRivetkitContext.broadcast("newCount", next.count);
				rawRivetkitContext.schedule.after(1_000, "tick", {});

				return next.count;
			}),
		});
	}),
	{
		state: {
			schema: Schema.Struct({ count: Schema.Number }),
			initialValue: () => ({ count: 0 }),
		},
		name: "Counter",
	},
);

Calls through rawRivetkitContext are not validated by effect/Schema and their payloads are typed as they are in the base RivetKit API.

Next Steps