Skip to main content
Features

Forking

Fork a Rivet Actor into a new actor that starts with the source's SQLite database without copying it, then runs on its own.

A fork is a new Rivet Actor that starts with everything another actor had saved: its SQLite database, its schedules, and its workflow progress. The fork gets its own ID and key, starts as soon as it is created, and then runs on its own. Writes to the fork never reach the source, and writes to the source never reach the fork. Use forks to branch an agent’s conversation, fan work out to copies that share the same context, or duplicate a document.

Forks are copy-on-write: a fork shares the source’s data instead of copying it, and stores only what it writes afterwards.

Source actorSQLite databaseForkSQLite databaseforkalice: 42bob: 7carol: 20dave: 5erin: 31frank: 13alice: 42bob: 8carol: 19dave: 5erin: 30frank: 12Written after the forkShared from the sourceSaved before the fork

Quickstart

Define an actor with onFork

onFork runs once, at the first start of a fork, in place of onCreate:

import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";
import { z } from "zod";

// Fork input comes from whoever calls fork, so validate it like a request body.
const ForkInput = z.object({ title: z.string().min(1).max(200) });

export const document = actor({
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS document (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					title TEXT NOT NULL,
					body TEXT NOT NULL
				);
			`);
			await db.execute(
				"INSERT OR IGNORE INTO document VALUES (1, 'Untitled', '')",
			);
		},
	}),

	// Runs once, at the first start of a fork, instead of onCreate.
	onFork: async (c, fork) => {
		const input = ForkInput.safeParse(fork.input);
		if (input.success) {
			await c.db.execute(
				"UPDATE document SET title = ? WHERE id = 1",
				input.data.title,
			);
		}
	},

	actions: {
		write: async (c, body: string) => {
			await c.db.execute(
				"UPDATE document SET body = ? WHERE id = 1",
				body,
			);
		},
		read: async (c) => {
			const [doc] = await c.db.execute<{ title: string; body: string }>(
				"SELECT title, body FROM document WHERE id = 1",
			);
			// The actor this one was forked from, or null for an original.
			const forkedFrom = c.forkChain.at(-1)?.actorId ?? null;
			return { title: doc.title, body: doc.body, forkedFrom };
		},
	},
});

export const registry = setup({ use: { document } });

registry.start();

Fork it

Fork an actor by its ID with client.<actor>.fork:

import { createClient } from "rivetkit/client";
import type { registry } from "./index";

const client = createClient<typeof registry>("http://localhost:6420");

const original = client.document.getOrCreate(["doc-1"]);
await original.write("First draft");
const originalId = await original.resolve();

// The fork starts right away, on a copy of the original's database.
const copy = await client.document.fork(originalId, {
	input: { title: "Copy of doc-1" },
});
await copy.write("Second draft");

console.log(await original.read());
console.log(await copy.read());

Check that the copies are independent

The fork starts with the original’s body and its own title, and each one keeps its own writes:

{ title: 'Untitled', body: 'First draft', forkedFrom: null }
{ title: 'Copy of doc-1', body: 'Second draft', forkedFrom: '<ID of doc-1>' }

Forking from a client

Call client.<actor>.fork with the source actor’s ID. It returns a handle to the new actor once its copy is ready:

import { createClient } from "rivetkit/client";
import type { registry } from "./quickstart/index";

const client = createClient<typeof registry>();
const sourceId = await client.document.getOrCreate(["doc-1"]).resolve();

// Fork doc-1 into a new actor with its own key. `input` reaches its onFork.
const draft = await client.document.fork(sourceId, {
	key: ["doc-1-draft"],
	input: { title: "Draft of doc-1" },
});

// The returned handle calls the new actor like any other.
await draft.write("A new opening paragraph");
console.log(await draft.read());
OptionDescription
keyThe new actor’s key. Omit it for a keyless actor.
inputAny CBOR-compatible value. It reaches the new actor’s onFork as fork.input.
params, signalConnection parameters for the returned handle, and a signal that aborts the call.
  • Call fork on the accessor for the source’s actor type, since a fork always has the source’s name and node pool.
  • The fork holds every write the source had saved when the call arrived. Writes the source has not saved yet, such as a transaction that is still open or anything in c.vars, are not in it.
  • A fork is not idempotent: each call makes a new actor, and a key that is already taken fails with actor.duplicate_key. Give forks a key when a retry must never make a second copy.

Forking from an actor

Inside an action, fork through the server-side actor client, c.client(). Pass c.actorId to fork the actor itself, or another actor’s ID to fork that one. The new actor’s onFork receives the input:

import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";

export const workspace = actor({
	db: db({
		onMigrate: async (db) => {
			await db.execute(
				"CREATE TABLE IF NOT EXISTS branch (id INTEGER PRIMARY KEY CHECK (id = 1), label TEXT)",
			);
		},
	}),

	// Runs in the new actor and receives the input that `branch` passed to fork.
	onFork: async (c, fork) => {
		const label =
			typeof fork.input === "string" ? fork.input.slice(0, 100) : null;
		await c.db.execute(
			"INSERT OR REPLACE INTO branch (id, label) VALUES (1, ?)",
			label,
		);
	},

	actions: {
		// Forks this actor and returns the new actor's ID.
		branch: async (c, label: string): Promise<string> => {
			const client = c.client<typeof registry>();
			const copy = await client.workspace.fork(c.actorId, {
				input: label,
			});
			return await copy.resolve();
		},
	},
});

export const registry = setup({ use: { workspace } });

See Forking Subagents for an agent that forks itself once per subtask.

onFork

onFork(c, fork) runs once, at the first start of a fork, after onMigrate and createVars and before onWake. createState and onCreate do not run for a fork.

FieldDescription
fork.actorIdThe source actor’s ID.
fork.keyThe source actor’s key, as an array.
fork.forkedAtWhen the fork was made, in milliseconds since the epoch.
fork.inputThe input passed to fork, or undefined. It comes from the caller.

Use it to cancel the schedules and cron jobs the fork copied, to drop or replace resources that belong to the source, and to record the fork input:

import { actor } from "rivetkit";
import { db } from "rivetkit/db";
import { z } from "zod";

const ForkInput = z.object({ label: z.string().max(100) });

const agent = actor({
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS fork_info (
					id INTEGER PRIMARY KEY CHECK (id = 1),
					source_id TEXT NOT NULL,
					label TEXT
				);
			`);
		},
	}),

	onFork: async (c, fork) => {
		// The source's pending schedules and cron jobs were copied with its database.
		// Cancel them so the fork does not repeat the source's work.
		for (const event of await c.schedule.list()) {
			await c.schedule.cancel(event.id);
		}
		for (const job of await c.cron.list()) {
			await c.cron.delete(job.name);
		}

		// fork.actorId, fork.key and fork.forkedAt come from Rivet. fork.input comes
		// from the caller, so it is validated, and invalid input is ignored rather
		// than thrown, since a throw fails the start and onFork runs again.
		const input = ForkInput.safeParse(fork.input);

		// INSERT OR REPLACE keeps the hook safe to run twice.
		await c.db.execute(
			"INSERT OR REPLACE INTO fork_info (id, source_id, label) VALUES (1, ?, ?)",
			fork.actorId,
			input.success ? input.data.label : null,
		);
	},

	actions: {},
});

Fork chain

c.forkChain lists every fork behind an actor, oldest first, each with actorId, key, and forkedAt. It is empty for an actor that was never forked, and c.forkChain.at(-1) is the actor this one was forked from. Namespace forks are not listed.

import { actor } from "rivetkit";

const document = actor({
	actions: {
		// The actor this one was forked from, or null for an original.
		forkedFrom: (c) => c.forkChain.at(-1)?.actorId ?? null,

		// Every fork behind this actor, oldest first.
		lineage: (c) =>
			c.forkChain.map((link) => ({
				actorId: link.actorId,
				key: link.key,
				forkedAt: new Date(link.forkedAt).toISOString(),
			})),
	},
});

Permissions

A token needs two grants to fork an actor:

  • fork on the source actor, by its ID or "any", because the fork copies the source’s data.
  • create on "any" actor, because the fork is a new actor whose ID is not known before the fork.

No other operation allows a fork: a token that can read, create, or connect to an actor cannot fork it. Issue one from your backend with client.auth.issueToken:

import { createClient } from "rivetkit/client";
import type { registry } from "./index";

// Runs on your backend. The endpoint carries your Rivet token.
const client = createClient<typeof registry>();

// A token that can fork doc-1 into new actors, and nothing else.
const sourceId = await client.document.getOrCreate(["doc-1"]).resolve();
const { token, expiresAt } = await client.auth.issueToken({
	subject: "user-123",
	expiresIn: 900,
	grants: [
		{ resource: "actor", target: { id: sourceId }, operations: ["fork"] },
		{ resource: "actor", target: "any", operations: ["create"] },
	],
});

Namespace forks need the admin token. See Scoped Tokens for issuing tokens with grants.

Namespace forks

A namespace fork copies a whole namespace into a new one: every actor, with the same actor IDs, keys, and databases as of the fork. Use it for a staging copy of production, or a preview environment with real data. Like an actor fork, it is copy-on-write.

curl -X POST "$RIVET_API/namespaces?wait=settled" \
  -H "Authorization: Bearer $RIVET_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"staging-copy","fork_from":"production"}'
  • Admin only. Namespace forks need the admin token.
  • Saved writes only. The fork includes every write the parent acknowledged before the call. ?wait=settled waits a few more seconds once the fork is live, so writes made after the call usually stay out of the fork. It does not guarantee it.
  • Answers once live. The new namespace’s status is forking, then live. If that takes longer than 30 seconds, the call answers with the namespace as it stands. Poll GET /namespaces?name= until it is live.
  • Node pools are not copied. Create the pools in the new namespace and connect nodes to it before its actors can run. See Namespaces.
  • onFork does not run. c.forkChain stays the same. c.namespace returns the new namespace, so an actor that owns something outside Rivet can tell that it is a copy. See Metadata.
  • Archive forks first. A namespace cannot be archived while a fork of it is not archived (namespace.has_forks).

See Create Namespace for every field and response.

Limits

LimitValue
Fork depth16 forks
Namespace fork depth3 forks below the original namespace
Fork call deadline30 seconds, then actor.fork_pending
onFork duration5 seconds on the native runtime
Fork first start15 seconds, including onFork

See Limits for details.

Guides

Edit this page Last updated November 11, 2026