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:
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, 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.
| 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 | Logger | 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.