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 control plane alongside the server. The control plane 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 control plane on http://localhost:6420. Clients connect directly to the control plane 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:
The Effect client imports the same actor contract from your registry. Counter.client yields a typed accessor backed by the client layer:
import { NodeRuntime } from "@effect/platform-node";
import { Client } from "@rivetkit/effect";
import { Effect } from "effect";
import { Counter } from "./actors/counter/api.ts";
const program = Effect.gen(function* () {
const counter = (yield* Counter.client).getOrCreate("my-counter");
const count = yield* counter.Increment({ amount: 3 });
yield* Effect.log(`New count: ${count}`);
const total = yield* counter.GetCount();
yield* Effect.log(`Total: ${total}`);
});
const ClientLayer = Client.layer({ endpoint: "http://localhost:6420" });
program.pipe(Effect.provide(ClientLayer), NodeRuntime.runMain);
With the server still running, start the client in another terminal:
npx tsx src/client.ts
See the chat-room-effect example for a larger project with typed errors and actor-to-actor calls.
A plain RivetKit client can call your Effect actor by name through the same engine. Actor and action names are resolved at runtime, so the client is untyped here:
import { createClient } from "rivetkit/client";
const client = createClient("http://localhost:6420");
const counter = client.Counter.getOrCreate(["my-counter"]);
const count = await counter.Increment({ amount: 3 });
console.log("New count:", count);
See the JavaScript client documentation for more information.
Deploy
By default, Rivet stores actor state on the local file system.
To scale Rivet in production, pick how much of it you want to run yourself:
Fully managed
Bring your own compute
Full self-hosting
If you are running your own workers, follow the guide for your hosting provider:
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.
| Feature | Effect-native API | Access |
|---|---|---|
| Actor contract & actions | Actor.make, Action.make | Typed |
| Persisted state | State.get / set / update / updateAndGet / changes | Typed |
| Typed client | Actor.client, Client.layer | Typed |
| Typed errors | RivetError | Typed |
| Logging | RivetLogger | Typed |
| Sleep request | Actor.Sleep | Typed |
Actor address (actorId / name / key) | Actor.CurrentAddress | Typed |
| Registry serve / test / web handler | Registry | Typed |
| Events & broadcast | Not yet wrapped | rawRivetkitContext.broadcast(...) |
| Schedule | Not yet wrapped | rawRivetkitContext.schedule.* |
| Embedded SQLite | Not yet wrapped | rawRivetkitContext.db.execute(...) |
| Destroy | Not yet wrapped | rawRivetkitContext.destroy() |
| Queues, connections, vars, alarms | Not yet wrapped | rawRivetkitContext.* |
Lifecycle hooks (onSleep / onDestroy) | Not yet wrapped | rawRivetkitContext.* |
| Raw HTTP / WebSocket handlers | Not yet wrapped | rawRivetkitContext.* |
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.