Effect.ts Quickstart (Beta)
Build a Rivet Actor with Effect, define typed services and actions, and connect an Effect application to durable actor state.
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. Durable data lives in the actor’s SQLite database, declared with db and queried through rawRivetkitContext.db:
import { Effect } from "effect";
import { db } from "rivetkit/db";
import { Counter } from "./api.ts";
export const CounterLive = Counter.toLayer(
Effect.fnUntraced(function* ({ rawRivetkitContext }) {
return Counter.of({
Increment: Effect.fnUntraced(function* ({ payload }) {
// Add the amount and read the new count in one atomic statement.
const [{ count }] = yield* Effect.tryPromise(() =>
rawRivetkitContext.db.execute<{ count: number }>(
"UPDATE counter SET count = count + ? WHERE id = 1 RETURNING count",
payload.amount,
),
).pipe(Effect.orDie);
// Broadcast the new value to every connected client.
rawRivetkitContext.broadcast("newCount", count);
return count;
}),
GetCount: () =>
Effect.tryPromise(async () => {
const [{ count }] = await rawRivetkitContext.db.execute<{ count: number }>(
"SELECT count FROM counter WHERE id = 1",
);
return count;
}).pipe(Effect.orDie),
});
}),
{
db: db({
onMigrate: async (client) => {
await client.execute(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
count INTEGER NOT NULL
)
`);
await client.execute("INSERT OR IGNORE INTO counter (id, count) VALUES (1, 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.
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:
Effect
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.
TypeScript
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
In development, Rivet runs a local control plane for you and keeps Actor data on your machine.
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 nodes, 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 |
| 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 |
Caller connection (id / scope) | connection on the action request | 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() |
| Connections, vars, alarms | Not yet wrapped | rawRivetkitContext.* |
Lifecycle hooks (onSleep / onDestroy) | Not yet wrapped | rawRivetkitContext.* |
| Raw HTTP / WebSocket handlers | Not yet wrapped | rawRivetkitContext.* |
Connection-Bound Resources
Action handlers receive connection, an Option with the caller’s connection id and scope. It is None for scheduled actions. The scope closes when the connection disconnects or the actor sleeps, so resources bound to it do not leak:
WatchChanges: ({ connection }) =>
Option.match(connection, {
onNone: () => Effect.die("WatchChanges needs a connection"),
onSome: ({ id, scope }) =>
Effect.acquireRelease(
Effect.sync(() => {
watches.add(id);
}),
() =>
Effect.sync(() => {
watches.delete(id);
}),
).pipe(Scope.provide(scope)),
}),
- Calls from
Actor.clientand other stateless calls use a connection that closes as soon as the action returns. To keep a resource open, call the action through a raw RivetKit client’s.connect(). - Clients must register their resources again after a reconnect, because a reconnect creates a new connection with a new
id. - The
idis an address, not an identity. Do not use it for authorization. - Keep finalizers short. The disconnect hook that closes the scope times out after 5 seconds.
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 raw context and the typed action handlers point at the same actor, so you can mix both:
export const CounterLive = Counter.toLayer(
Effect.fnUntraced(function* ({ rawRivetkitContext }) {
return Counter.of({
// Typed, schema-validated action handler
Increment: Effect.fnUntraced(function* ({ payload }) {
// Untyped features run through the raw context
const [{ count }] = yield* Effect.tryPromise(() =>
rawRivetkitContext.db.execute<{ count: number }>(
"UPDATE counter SET count = count + ? WHERE id = 1 RETURNING count",
payload.amount,
),
).pipe(Effect.orDie);
rawRivetkitContext.broadcast("newCount", count);
rawRivetkitContext.schedule.after(1_000, "tick", {});
return count;
}),
});
}),
{
db: db({
onMigrate: async (client) => {
await client.execute(
"CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY CHECK (id = 1), count INTEGER NOT NULL)",
);
await client.execute("INSERT OR IGNORE INTO counter (id, count) VALUES (1, 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.