Design constraints, invariants, and reference commands for the Rivet monorepo. For implementation details, wiring, and procedural gotchas, follow the links under Reference Docs.
ALWAYS use rivet.dev - NEVER use rivet.gg
- API endpoint:
https://api.rivet.dev - Cloud API endpoint:
https://cloud-api.rivet.dev - Dashboard:
https://dashboard.rivet.dev - Documentation:
https://rivet.dev/docs
Use "sandbox mounting" when referring to the agentOS sandbox integration. Do not use "sandbox extension" or "sandbox escalation." The feature mounts a sandbox as a filesystem inside the VM.
ALWAYS use github.com/rivet-dev/rivet - NEVER use rivet-dev/rivetkit or rivet-gg/*
Never modify an existing published *.bare runner protocol version unless explicitly asked to do so.
- Add a new versioned schema instead, then migrate
versioned.rsand related compatibility code to bridge old versions forward. - When bumping the protocol version, update
PROTOCOL_MK2_VERSIONinengine/packages/runner-protocol/src/lib.rsandPROTOCOL_VERSIONinrivetkit-typescript/packages/engine-runner/src/mod.tstogether. Both must match the latest schema version.
Always use versioned BARE (vbare) instead of raw serde_bare for any persisted or wire-format encoding unless explicitly told otherwise. Raw serde_bare::to_vec / from_slice has no version header, so any future schema change forces hand-rolled LegacyXxx fallback structs. vbare::OwnedVersionedData plus a versioned *.bare schema is the standard pattern. Acceptable raw-bare exceptions: ephemeral in-memory encodings that never cross a process boundary or hit disk, and wire formats whose protocol version is coordinated out-of-band (e.g. an HTTP path like /v{PROTOCOL_VERSION}/... or another channel that pins both peers to one schema per call).
- Avoid raw
f64fields in vbare protocol schemas that use hashable maps; generated Rust derivesEq/Hash, so encode floats as fixed bytes or an ordered wrapper. - Version converters must manually map fields between versions; never use serialize-deserialize round trips such as
transcode_versionorserde_bare::to_vecplusfrom_slice. - RivetKit client/server protocol compatibility assumes the server/runtime is newer than the client; clients send their latest request protocol version, and servers handle older-client compatibility and negotiation.
When talking about "Rivet Actors" make sure to capitalize "Rivet Actor" as a proper noun and lowercase "actor" as a generic noun.
# Check a specific package without producing artifacts (preferred for verification)
cargo check -p package-name
# Build
cargo build
cargo build -p package-name
cargo build --release
# Test
cargo test
cargo test -p package-name
cargo test test_name
cargo test -- --nocapture# Run linter (but see "Development warnings" below)
./scripts/cargo/fix.sh
# Check for linting issues
cargo clippy -- -W warnings- Agents may run
node scripts/format/agent-format.mjsto format changed Biome and Rust files. Do not run broadcargo fmtorcargo fmt --allmanually. - Do not run
./scripts/cargo/fix.shor other broad formatter/fixer commands yourself. - Ensure lefthook is installed and enabled for git hooks (
lefthook install).
cd self-host/compose/dev
docker-compose up -d- Do not edit
self-host/compose/dev*configs directly. Edit the template inself-host/compose/template/and rerun (cd self-host/compose/template && pnpm start) to regenerate. - Rebuild publish base images with
scripts/docker-builder-base/build-push.sh <base-name|all> --push. UpdateBASE_TAGwhen rebuilding shared builder bases; engine bases are published per commit inpublish.yaml.
- This repo uses jj (Jujutsu) on top of git. jj's workflow is inverted from git: the working copy is itself a revision that auto-tracks edits, so you create a new revision before making changes (with
jj new) rather than committing after (git commit). The description is set separately viajj describe. There is no staging step. - Before making changes, check whether jj is initialized by running
jj status. If it fails (e.g. "There is no jj repo in '.'"), runjj git init --colocatefrom the repo root so jj lives alongside the existing.gitdirectory. Do NOT runjj git initwithout--colocate— that creates a standalone jj repo and breaks the git workflow. - MUST run
jj newbefore making any file edits for a new task. This is the first step of any task that touches files. Run it before reading, before planning, before editing. The only exception is when you are directly fixing or finishing the change at@that you just made in this same session. In that case usejj squash --into <rev>orjj edit <rev>. If you already started editing without runningjj new, stop and split the changes withJJ_EDITOR=true jj split <paths>before continuing. Each revision must be one self-contained change reviewable on its own. Never mix unrelated work into one revision. - Set the revision description with
jj describe -m "{conventional commit message}"— a single-line conventional commit (feat,fix,chore,docs,refactor, etc.) with an optional scope. Examples:feat(metrics): record depot sqlite phase timings,fix(pegboard): handle empty ack batch. - Commit titles and PR titles are pure conventional commits. Never indicate that a change was written by a coding agent: no model name, no agent name, no
[SLOP(...)]prefix in the title, body, or PR text, and noCo-Authored-By:orGenerated withtrailer. The title must read exactly as a human-authored conventional commit. jj descriptions stay single-line. - PR descriptions are a simple, high-level bullet list of what changed. One bullet per meaningful change in plain language. No per-file or line-by-line detail, no implementation narration, and no mention of an agent.
- Never push to
mainunless explicitly specified by the user. - Safety: Never run destructive jj or git commands (
jj git push,jj abandon,jj squashinto a non-current revision,jj op restore,jj op undopast your own work,jj rebase -d main,git push --force,git reset --hard) unless the user explicitly requests it.
- Large public dashboard and website media belongs in the
rivet-assetsR2 bucket, not Git. - Use object keys shaped like
dashboard/{group}/{asset-name}for dashboard media andwebsite/blog/{post}/{asset-name}for blog or changelog post media. - Upload with
op://Engineering/rivet-assets R2 Upload/{username,password}andaws s3 cp <file> s3://rivet-assets/<key> --endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com. - For blog or changelog hero media, upload the file to R2 as
website/blog/{post-slug}/image.{ext}and set frontmatterimage: true(orimage: { format: "gif" }for a non-png). The URL is derived from the slug and dimensions are fixed at a 2:1 ratio, so do not write the absolute URL, width, or height. Resolved bywebsite/src/lib/postImage.ts. - Do not use Git LFS in this repo; jj can snapshot raw working-tree bytes.
- For any frontend visual change, use the
agent-browserskill to view the result in a browser instead of working blind. If it is not installed, prompt the user to install it. - The frontend dashboard dev server always runs at
http://localhost:43708/. Check that it is already serving there before starting a new one. - The same change can render differently per deployment flavor. Verify visual changes in OSS and cloud (at minimum), not just whichever flavor the dev server defaults to. See Frontend Feature Flags.
- Manage all user-submitted form state with
react-hook-form. Do not hand-roll form state withuseState-driven controlled inputs. Wrapping a controlled UI-library input (e.g. a shadcnSelect) in RHF's<Controller>is still react-hook-form owning the state and is fine.
- The dashboard serves multiple deployment flavors (cloud / OSS / enterprise) from one build via flags in
frontend/src/lib/features.ts; consume them throughimport { features } from "@/lib/features", never by readingVITE_FEATURE_FLAGSdirectly. - Ship a new pack of features behind a feature flag whenever it is significant (a whole panel/page/subsystem) or not universally available across flavors, so each flavor can freely enable or disable it. Do not gate small universal changes, and do not make flags for everything.
- If unsure whether a feature needs a flag, confirm the need with the user before adding or omitting one.
- Test frontend changes across flavors before calling them done, especially OSS (it turns the most off and regresses most often). Switch flavors in dev by setting
localStorage.FEATURE_FLAGSand reloading. See Feature flags for the flag list, flavor mapping, the per-flavor flag sets, and consistency rules.
context()runs at match creation time — its return value is part ofmatch.contextand readable viauseRouteContext. Use it for synchronous context setup (e.g. creating a data provider from params).beforeLoad()return value goes intomatch.__beforeLoadContextand is never merged back intomatch.context.useRouteContextwill not see it — components reading viauseRouteContextget a stale snapshot from beforebeforeLoadran.- For async-computed values (e.g. a data provider that depends on a fetched namespace), return the value from
loader()instead and read it in components viauseLoaderData. The loader receives the full merged context includingbeforeLoadresults as a function argument, so it can re-export the computed value intomatch.loaderData. - Rule of thumb: sync setup →
context()+useRouteContext. Async setup →beforeLoad(for child route access) +loaderreturn +useLoaderData(for component access).
- Every route that owns a data provider sets it up in
context()(sync) orbeforeLoad(async) AND re-exports it fromloaderas{ dataProvider: context.dataProvider }. All consumer hooks insrc/components/actors/data-provider.tsxread viauseLoaderData. Do not read data providers viauseRouteContext—match.contextis a snapshot taken at match creation time and does not includebeforeLoadresults.
- Prefer the Tokio-shaped APIs from
antiox(antiox/sync/mpsc,antiox/task, etc.) over ad hoc Promise queues, custom channel wrappers, or event-emitter coordination. rivet-envoy-clienttransport features are mutually exclusive; native builds use the defaultnative-transport, while wasm builds must setdefault-features = falseand enablewasm-transport.rivet-envoy-clientwasm WebSocket code lives behindtarget_arch = "wasm32"with a native-hostwasm-transportstub so feature checks do not compile browser APIs on developer machines.rivetkit-corewasm builds use--no-default-features --features wasm-runtime,sqlite-remote; keep native process and runner-config HTTP code behindnative-runtime.- Core-owned lifecycle tasks in
rivetkit-coreshould spawn throughRuntimeSpawnerso native builds use Send-capable tasks and wasm builds use local tasks. rivet-envoy-client::async_counter::AsyncCounteris the shared HTTP request counter type consumed by core sleep logic; do not pullrivet-utilinto core for that counter.- For
wasm32-unknown-unknownRust checks, use target-specific minimal Tokio plusgetrandom/jsanduuid/js; scan production dependencies withcargo tree -e normalso dev-dependencies do not create false native-dependency hits. - Use
scripts/cargo/check-rivetkit-core-wasm.shas the canonical wasm gate forrivetkit-core; it checks the wasm build, scans native dependency leaks, and verifies native transport/runtime features fail on wasm. - The high-level
rivetkitcrate stays a thin typed wrapper overrivetkit-coreand re-exports shared transport/config types instead of redefining them. - When
rivetkitneeds ergonomic helpers on arivetkit-coretype it re-exports, prefer an extension trait pluspreludere-export instead of wrapping and replacing the core type. - RivetKit action and event protocol
argsmust always be array-shaped before crossing the client protocol boundary. Normalize at the server/source side, not in client delivery code: named structs/objects become[object], tuples/arrays stay positional, scalars become[scalar], and unit/null becomes[]. engine/sdks/*/api-*are auto-generated SDK outputs; update the source API schema and regenerate them instead of editing them by hand.
- Core tests that touch the
_RIVET_TEST_INSPECTOR_TOKENenv override must share a process-wide lock with startup tests that assert inspector-token initialization side effects; otherwise parallelcargo testruns can flipinit_inspector_token(...)between the env-override no-op path and the KV-backed path. - For the fast static/http/bare driver verifier, pass only the files listed under
## Fast Testsin~/.agents/notes/driver-test-progress.md;tests/driver/*.test.tsalso pulls in slow-suite files and gives bogus gate failures. - Wasm host smoke tests can drive
buildNativeFactorythroughWasmCoreRuntimefake bindings to cover actor callbacks, KV, state serialization, remote SQLite routing, and NAPI import boundaries without checked-in wasm-pack output. - When moving Rust inline tests out of
src/, keep a tiny source-owned#[cfg(test)] #[path = "..."] mod tests;shim so the moved file still has private module access without widening runtime visibility. Prefer a dedicated moved-test file per source module; reusing stale sharedtests/modules/*.rsfiles can silently rot against private APIs and explode once you wire them back in. - Tracing assertions on spawned Rust futures should bind an explicit
tracing::Dispatchwith.with_subscriber(...)on the spawned future; thread-localset_default(...)can miss the real logs in full async suite runs.
- Depot client owns native SQLite VFS and query execution in
engine/packages/depot-client/; core owns lifecycle, and NAPI only marshals JS types. - SQLite VFS direct tests should record workflow compaction wakes through
CompactionSignaler, not call legacycompact_default_batch. - SQLite VFS correctness tests should use
DirectStorage; do not reintroduce mock or envoy transport variants for those tests. - SQLite VFS
xSyncdurability depends on depot'ssqlite_commitreply waiting for the FDB transaction commit. - SQLite VFS process-global registrations must be owned by a Drop guard so panics unwind through
sqlite3_vfs_unregister. NativeDatabase::Dropmust bound dirty-page flushes with a short timeout and return after logging if the commit future never resolves.- Actor2 workflows and envoy actors always use the SQLite v2 storage format; only old actor v1 workflows and pegboard runners use the v1 storage format. ("v2" here refers to the on-disk storage format, not envoy-protocol v2.)
- Native SQLite VFS recent-page preload hints are actor-side Rust state surfaced by
NativeDatabase::snapshot_preload_hints(); persist and consume them through runtime/envoy wiring, not JS APIs. - SQLite VFS file handles must enforce their reader or writer role; reader-owned handles fail closed on mutating callbacks.
- Native SQLite single-statement work should route through the native execute path; keep
execas the multi-statement compatibility path. - Native SQLite opens should use
depot_client::database::open_database_from_envoyinstead of directrusqlitecalls so native routing policy stays shared. - Pegboard-envoy remote SQL executor caches should use
Arc<OnceCell<NativeDatabaseHandle>>values so first-use initialization stays lazy and single-flight per(actor_id, sqlite_generation). - Sent remote SQL requests must fail with
sqlite.remote_indeterminate_resulton WebSocket disconnect; only unsent remote SQL may be sent after reconnect. - Native SQLite manual transactions keep an idle writer open until autocommit returns; route subsequent work through the writer instead of reader classification.
- Native SQLite read mode may hold multiple read-only connections, while write mode must hold exactly one writable connection and no readers; TypeScript must not be the routing policy boundary.
- For NAPI bridge wiring (TSF callback layout, cancellation tokens,
#[napi(object)]rules), seedocs-internal/engine/napi-bridge.md.
All agent working files live user-scoped in ~/.agents/, never inside the repo. Override the location with the AGENTS_DIR env var. Run scripts/setup/agents-dir.sh once to create the subdirs. These files are not committed; .agent/ is gitignored as a safety net.
- Specs:
~/.agents/specs/— design specs and interface definitions for planned work. - Research:
~/.agents/research/— research documents on external systems, prior art, and design analysis. - Todo:
~/.agents/todo/*.md— deferred work items with context on what needs to be done and why. - Notes:
~/.agents/notes/— general notes and tracking. - Benchmarks:
~/.agents/benchmarks/— benchmark result artifacts.
When the user asks to track something in a note, store it in ~/.agents/notes/ by default. When something is identified as "do later", add it to ~/.agents/todo/. Design documents and interface specs go in ~/.agents/specs/.
-
rivetkit-core is the source of truth for all load-bearing functionality. rivetkit (TypeScript) is the flagship user-facing implementation. rivetkit (Rust) is a preview API that should be kept up to date with rivetkit-typescript on a best-effort basis; it may lag behind, but new user-facing capabilities added to rivetkit-typescript should be mirrored where practical.
-
Engine (
packages/core/engine/, includes Pegboard + Pegboard Envoy) — Orchestration. Manages actor lifecycle, routing, KV, SQLite, alarms. In local dev, the engine is spawned alongside RivetKit. -
envoy-client (
engine/sdks/rust/envoy-client/) — Wire protocol between actors and the engine. BARE serialization, WebSocket transport, KV request/response matching, SQLite protocol dispatch, tunnel routing. -
rivetkit-core (
rivetkit-rust/packages/rivetkit-core/) — Core RivetKit logic in Rust, language-agnostic. Lifecycle state machine, sleep logic, shutdown sequencing, state persistence, action dispatch, event broadcast, queue management, schedule system, inspector, metrics. All callbacks are dynamic closures with opaque bytes. All load-bearing logic must live here. Config conversion helpers and HTTP request/response parsing for foreign runtimes belong here. -
rivetkit (Rust) (
rivetkit-rust/packages/rivetkit/) — Rust-friendly typed API.Actortrait,Ctx<A>,Registrybuilder, CBOR serde at boundaries. Thin wrapper over rivetkit-core. No load-bearing logic. -
rivetkit-napi (
rivetkit-typescript/packages/rivetkit-napi/) — NAPI bindings only. ThreadsafeFunction wrappers, JS object construction, Promise-to-Future conversion. No load-bearing logic. Must only translate between JS types and rivetkit-core types. Only consumed byrivetkit-typescript/packages/rivetkit/. -
rivetkit (TypeScript) (
rivetkit-typescript/packages/rivetkit/) — TypeScript-friendly API. Calls into rivetkit-core via NAPI for lifecycle logic. Owns workflow engine, agent-os, and client library. Zod validation for user-provided schemas runs here.
- All actor-runtime lifecycle logic, state persistence, sleep/shutdown, action dispatch, event broadcast, queue management, schedule, inspector, and metrics must live in rivetkit-core. No actor-runtime lifecycle logic in TS or NAPI.
- The rivetkit (TypeScript) client (
rivetkit-typescript/packages/rivetkit/src/client/) is exempt from the core-only rule. Client-side dispatch retry, stale-handle resolution, lifecycle-error classification, and reconnection logic stay in TypeScript and are not duplicated in rivetkit-core. The client runs in the user's process, not on the actor host. - rivetkit-napi must be pure bindings. If code would be duplicated by a future V8 runtime, it belongs in rivetkit-core instead.
- rivetkit-napi serves through
CoreRegistry+NapiActorFactory; do not reintroduce the deletedBridgeCallbacksJSON-envelope envoy path orstartEnvoy*Jsexports. - NAPI
ActorContext.sql()returnsJsNativeDatabasedirectly; do not reintroduce a standaloneSqliteDbwrapper export. - TypeScript runtime adapters expose
CoreRuntimefromrivetkit/src/registry/runtime.ts; keep raw@rivetkit/rivetkit-napiand future@rivetkit/rivetkit-wasmimports insidesrc/registry/*-runtime.ts. - rivetkit (Rust) is a thin typed wrapper. If it does more than deserialize, delegate to core, and serialize, the logic should move to rivetkit-core.
- rivetkit (TypeScript) owns only: workflow engine, agent-os, client library, Zod schema validation for user-defined types, and actor definition types.
- Errors use universal
RivetError(group/code/message/metadata) at all boundaries. No custom error classes in TS. - CBOR serialization at all cross-language boundaries. JSON only for HTTP inspector endpoints.
- Pegboard orchestrates actor exclusivity: at most one actor instance for a given actor id may be running or accessing that actor's KV at a time.
pegboard-envoyandenvoy-clientmay rely on this invariant and should not add separate KV concurrency fences for same-actor access; the lost-timeout + ping protocol is responsible for making overlapping actors impossible.
- Core Engine (
packages/core/engine/) — main orchestration service. - Workflow Engine (
packages/common/gasoline/) — multi-step operations with reliability + observability. - Pegboard (
packages/core/pegboard/) — actor/server lifecycle management. - Pegboard Envoy (
engine/packages/pegboard-envoy/) — active actor-to-engine bridge (successor to pegboard-runner). - Common packages (
packages/common/) — foundation utilities, DB pools, caching, metrics, logging, health, gasoline core. - Core packages (
packages/core/) — engine executable, pegboard orchestration, workflow workers. - Shared libraries (
shared/{language}/{package}/) — shared between engine and rivetkit (e.g.,shared/typescript/virtual-websocket/). - Databases: UniversalDB (distributed state), ClickHouse (analytics/time-series). Connection pooling via
packages/common/pools/. - Services communicate via NATS with service discovery.
engine/packages/pegboard-runner/,engine/sdks/typescript/runner,rivetkit-typescript/packages/engine-runner/, and associated runner workflows are deprecated. All new actor hosting work targetsengine/packages/pegboard-envoy/exclusively. Do not add features to or fix bugs in the deprecated runner path.
- Keep
engine/sdks/typescript/runnerandengine/sdks/rust/engine-runnerat feature parity. - Any behavior, protocol handling, or test coverage added to one runner should be mirrored in the other runner in the same change whenever possible.
- When parity cannot be completed in the same change, explicitly document the gap and add a follow-up task.
- Treat
client <-> engineas untrusted. - Treat
envoy <-> pegboard-envoyas untrusted. - Treat traffic inside the engine over
nats,fdb, and other internal backends as trusted. - Treat
gateway,api,pegboard-envoy,nats,fdb, and similar engine-internal services as one trusted internal boundary once traffic is inside the engine. - Validate and authorize all client-originated data at the engine edge before it reaches trusted internal systems.
- Validate and authorize all envoy-originated data at
pegboard-envoybefore it reaches trusted internal systems.
- Reject WebSocket connections (auth failures, routing errors, any rejection reason) by accepting the upgrade and sending a close frame with a meaningful close code and
<group>.<code>reason. Do not reject with an HTTP status before the upgrade. Browser clients cannot surface HTTP status on a failed upgrade; they only seeCloseEvent.code/.reason, so pre-upgrade rejection leaves them with no diagnostic. Use close code1008(policy violation) for auth failures, matching theinspector.unauthorizedconvention.
- Avoid silent no-ops for required runtime behavior. If a capability is required, validate it and throw an explicit error with actionable context instead of returning early.
- Do not use optional chaining for required lifecycle and bridge operations (for example sleep, destroy, alarm dispatch, ack, and websocket dispatch paths).
- Optional chaining is acceptable only for best-effort diagnostics and cleanup paths (for example logging hooks and dispose/release cleanup).
- Keep scaffolded
rivetkit-corewrappersDefault-constructible, but return explicit configuration errors until a realEnvoyHandleis wired in. - Keep foreign-runtime-only
ActorContexthelpers present on the public surface even before NAPI or V8 wires them. Make them fail with explicit configuration errors instead of silently disappearing. - In
rivetkit-coreActorTask::run, bind inboxrecv()calls as rawOptions and log the closed channel before terminating.Some(...) = recv()pluselse => breakhides which inbox died. - In
rivetkit-typescript/packages/rivetkit/src/common/utils.ts::deconstructError, only passthrough canonical structured errors (instanceof RivetErroror tagged__type: "RivetError"with full fields). Plain-object lookalikes must still be classified and sanitized. - Actor-owned lifecycle / dispatch / lifecycle-event inbox producers use
try_reservehelpers and returnactor.overloaded. Do not await boundedmpsc::Sender::send.
- Use
rivet_perf::{perf_start, perf_finish}for latency-sensitive async phases, shared I/O wrappers, and suspected backpressure points where slow-tail spans are useful. - Keep
perf_start!labels bounded for metrics; put high-cardinality values such as IDs, request paths, and byte counts in span fields instead. - Every
PerfMeasuremust end withperf_finish!orperf_abandon!before leaving scope. - Never use
Mutex<HashMap<...>>orRwLock<HashMap<...>>. Usescc::HashMap(preferred),moka::Cache(for TTL/bounded), orDashMapfor concurrent maps. - Use
scc::HashSetinstead ofMutex<HashSet<...>>for concurrent sets. sccasync methods do not hold locks across.awaitpoints. Useentry_asyncfor atomic read-then-write.- Hold lock guards for as short as possible, including
sccguards fromget_asyncand related methods. Clone/copy needed data anddrop(...)before async work, as insend_and_check_pinginengine/packages/pegboard-gateway2/src/shared_state.rs. - Never poll a shared-state counter with
loop { if ready; sleep(Nms).await; }. Pair the counter with atokio::sync::Notify(orwatch::channel) that every decrement-to-zero site pings, and wait withAsyncCounter::wait_zero(deadline)or an equivalentnotify.notified()+ re-check guard that arms the permit before the check. - Every shared counter with an awaiter must have a paired
Notify,watch, or permit. Waiters must arm the notification before re-checking the counter so decrement-to-zero cannot race past them. - Reserve
tokio::time::sleepfor: per-call timeouts viatokio::select!, retry/reconnect backoff, deliberate debounce windows, orsleep_until(deadline)arms in an event-select loop. If it is inside aloop { check; sleep }body, it is polling and should be event-driven instead. - Never add unexplained wall-clock defers like
sleep(1ms)to decouple a spawn from its caller. Usetokio::task::yield_now().awaitor rely on the spawn itself.
- Do not introduce intentional leaks (
Box::leak,std::mem::forget,*_into_rawwithout matching cleanup) unless an upstream API makes ownership impossible to express safely. - Never call
Box::leakinside a per-request, per-error, or per-call code path; if a'staticreference is required, use a compile-timestatic/constor intern it through a process-global map keyed by identity. - Interned leaks must be bounded by unique schema/config identity and must not include unbounded user input such as raw error messages, SQL, actor keys, request paths, or headers.
std::mem::forgetis only acceptable when an FFI handle cannot be dropped in the current context; document the constraint inline, prove the leak is bounded, and prefer routing cleanup through an Env-bearing owner.- Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a
CancellationTokenwhose clones are guaranteed to drop). Aspawn_local(async move { token.cancelled().await; ... })only drains if every clone of the token is dropped or cancelled.
- Async Rust code defaults to
tokio::sync::Mutex/tokio::sync::RwLock. Do not usestd::sync::Mutex/std::sync::RwLock. - Use
parking_lot::Mutex/parking_lot::RwLockonly when sync is mandated by the call context:Drop, sync traits, FFI/SQLite VFS callbacks, or sync&selfaccessors. rivetkit-napisync N-API methods, TSF callback slots, and testMakeWritercaptures are forced-sync contexts. Useparking_lotthere and keep guards out of awaits.rivetkit-napitest-only global serialization should use a realparking_lotguard instead ofAtomicBoolspin loops.- If an external dependency's struct requires
std::sync::Mutex, keep it at the construction boundary with an explicit forced-std-sync comment. - Prefer async locks because sync guards can be silently held across
.await, poisoning creates.expect("lock poisoned")boilerplate, and the tiny uncontended-lock win is dwarfed by actor I/O latency.
- Custom error system at
packages/common/error/using#[derive(RivetError)]on struct definitions. For the full derive example and conventions, see.claude/reference/error-system.md. - Always return anyhow errors from failable functions. Do not glob-import from anyhow. Prefer
.context()over theanyhow!macro. rivetkit-coreshould convert callback/actionanyhow::Errorvalues into transport-safegroup/code/messagepayloads withrivet_error::RivetError::extractbefore returning them across runtime boundaries.rivetkit-coreis the single source of truth for cross-boundary error sanitization. The TS bridge must NOT pre-wrap non-structured JS errors into a canonicalRivetErrorbefore bridge-encoding. Pass rawErrorvalues through the bridge as unstructured strings so core'sRivetError::extracthitsbuild_internaland produces the sanitizedINTERNAL_ERRORpayload. Only TS errors that never cross into core (HTTP router parsing, Hono middleware) should be sanitized bycommon/utils.ts::deconstructError. The dev-mode toggle that exposes raw messages lives in core (reads env atbuild_internal), not in the TS bridge.envoy-clientactor-scoped HTTP fetch work should stay in aJoinSetplus anArc<AtomicUsize>counter so sleep checks can read in-flight request count and shutdown can abort and join the tasks before sendingStopped.
- Use tracing. Never use
eprintln!orprintln!for logging in Rust code. Always usetracing::info!,tracing::warn!,tracing::error!, etc. - Do not format parameters into the main message. Use structured fields:
tracing::info!(?x, "foo")instead oftracing::info!("foo {x}"). - Log messages should be lowercase unless mentioning specific code symbols.
tracing::info!("inserted UserRow")instead oftracing::info!("Inserted UserRow"). rivetkit-coreruntime logs should includeactor_idand stable structured fields such asreason,kind,delta_count, byte counts, and timestamp fields instead of payload debug dumps.
- Always include
namespace_idas a metric label when applicable. Bounded cardinality, useful for per-namespace dashboards. - Never use unbounded labels. No
actor_id,actor_key,envoy_key,runner_id,workflow_id, request_id, etc. Each unbounded label creates a new time series per value; under load this produces millions of series and OOMs the metrics pipeline. - Other safe labels:
pool_name,runner_name,workflow_name,activity_name,operation_name,state,result,reason,error,status— all bounded by code-defined enums or by namespace count. - Standard label names (match existing conventions):
- HTTP error class →
error(noterror_kindorerror_type) - HTTP status code →
status(notstatus_codeorhttp_status) - Operation success/failure →
result(notoutcome) - State-transition cause →
reason
- HTTP error class →
- Never emit metrics from inside a
#[workflow]function body. Workflow functions are replayed deterministically by gasoline; metric increments inside them fire on every replay. Move the increment into an#[activity]or#[operation]that the workflow calls. - Gauges of integer-valued state use
IntGauge/IntGaugeVec, notGauge/GaugeVec. - Reuse existing histogram bucket constants from
engine/packages/metrics/src/buckets.rs(BUCKETS,MICRO_BUCKETS,LIFETIME_BUCKETS, etc.) rather than inventing new bucket arrays per metric. Add a new constant tobuckets.rsonly if no existing constant covers the value range.
- Never use
vi.mock,jest.mock, or module-level mocking. Write tests against real infrastructure (Docker containers, real databases, real filesystems). For LLM calls, use@copilotkit/llmockto run a mock LLM server. For protocol-level test doubles (e.g., ACP adapters), write hand-written scripts that run as real processes.vi.fn()for simple callback tracking is acceptable. - Driver tests that wait for actor sleep must not poll actor actions while waiting; each action counts as activity and can reset the sleep deadline.
- Never paper over flakes with retry loops or bumped waits. When a test flakes, (1) root-cause the race, (2) write a deterministic repro using
vi.useFakeTimers()or event-orderedPromiseresolution, (3) fix the underlying ordering in core/napi/typescript, (4) delete any flake-workaround note.vi.waitForis acceptable for legitimate "wait for an async event" coordination but never as a retry-until-success masking layer. Everyvi.waitForcall must have a one-line comment explaining why polling rather than direct awaiting is necessary. - In
rivetkit-typescript/packages/rivetkit/tests/, put thevi.waitFor(...)justification on the immediately preceding//line.pnpm run check:wait-for-commentsenforces the adjacent comment. - Rust tests live under
tests/, not inline#[cfg(test)] mod testsinsrc/. Move every inline test module in Rust crates to the crate'stests/directory. Exceptions must be justified (e.g., testing a private internal that can't be reached from an integration test). - For running RivetKit tests, Vitest filter gotchas, the driver-test parity workflow, and Rust test layout rules, see
.claude/reference/testing.md.
- Keep
@rivetkit/traceschunk writes under the 128 KiB actor KV value limit. Use 96 KiB chunks unless a multipart reader/writer replaces the single-value format.
- Data structures often include:
id(uuid)name(machine-readable name, must be valid DNS subdomain, convention is using kebab case)description(human-readable, if applicable)
- Use UUID (v4) for generating unique identifiers.
- Store dates as i64 epoch timestamps in milliseconds for precise time tracking.
- Timestamps use
*_atnaming with past-tense verbs. For example,created_at,destroyed_at.
- Hard tabs for Rust formatting (see
rustfmt.toml). - Follow existing patterns in neighboring files.
- Always check existing imports and dependencies before adding new ones.
- Always add imports at the top of the file instead of inline within a function.
- Write comments as normal, complete sentences. Avoid fragmented structures with parentheticals and dashes like
// Spawn engine (if configured) - regardless of start kind. Instead, write// Spawn the engine if configured. Especially avoid dashes (hyphens are OK). - Never use em dashes (—) in any plain-English writing (docs, comments, PR descriptions, prose). Use periods to separate sentences instead.
- Documenting deltas is not important or useful. A developer who has never worked on the project will not gain extra information if you add a comment stating that something was removed or changed because they don't know what was there before. The only time you would be adding a comment for something NOT being there is if its unintuitive for why its not there in the first place.
- Never use a
_ =>fall-through arm when matching on a Rust enum (or a TypeScript discriminated union). Enumerate every variant explicitly so adding a new variant later is a compile error instead of a silent behavior change._is fine forResult,Option, integers, strings, and other open value spaces._ => unreachable!()/_ => panic!()are explicit asserts and acceptable.
- If you need to look at the documentation for a package, visit
https://docs.rs/{package-name}. For example, serde docs live athttps://docs.rs/serde/. - When adding new docs pages, update
website/src/sitemap/mod.tsso the page appears in the sidebar. - For the full docs-sync table (limits, config, actor errors, statuses, k8s, landing, sandbox providers, inspector), see
.claude/reference/docs-sync.md.
- When adding entries to any CLAUDE.md file, keep them concise. Ideally a single bullet point or minimal bullet points. Do not write paragraphs.
- Only add design constraints, invariants, and non-obvious rules that shape how new code should be written. Do not add general trivia, current implementation wiring, KV-key layouts, module organization, API signatures, ephemeral migration state, or anything a reader can learn by reading the code. That content belongs in module doc-comments,
docs-internal/, or.claude/reference/. - When the user asks to update any
CLAUDE.md, add one-line bullet points only, or add a new section containing one-line bullet points. - Architectural internals and runtime wiring belong in
docs-internal/engine/. Agent-procedural guides (test-harness gotchas, build troubleshooting, docs-sync tables) belong in.claude/reference/. Link them from the Reference Docs index below instead of inlining. - Every directory that has a
CLAUDE.mdmust also have anAGENTS.mdsymlink pointing to it (ln -s CLAUDE.md AGENTS.mdfrom the same directory). When creating a newCLAUDE.md, create the symlink in the same change.
Load these only when the task touches the topic.
- rivetkit-core internals — KV-key layout, storage organization on
ActorContextInner, startup/shutdown sequences, inspector attach plumbing, schedule dirty-flag, registry dispatch. Read before changing state persistence, lifecycle, or registry wiring. - rivetkit-core state management —
request_save/save_state/persist_state/set_state_initialsemantics. Keep in sync when changing state APIs. - ActorTask dispatch —
DispatchCommand::Action/Http/OpenWebSocket,UserTaskKindchildren,ActorTaskmigration status. Read before changing actor task routing. - Inspector protocol — HTTP↔WebSocket mirroring rules, wire-version negotiation,
inspector.*_droppeddowngrades, workflow inspector inference. Read before touching inspector endpoints. - NAPI bridge — TSF callback slots,
ActorContextSharedcache reset,#[napi(object)]payload rules, cancellation token bridging, error prefix encoding. Read before touchingrivetkit-napi. - Envoy load balancing — Hash-ring layout, virtual nodes, allocator flow, stale-envoy expiry, and tuning. Read before touching pegboard envoy allocation.
- BARE protocol crates — vbare schema ordering, identity converters,
build.rsTS codec generation pattern. Read before adding/changing protocol crates. - SQLite VFS parity — native Rust VFS ↔ WASM TypeScript VFS 1:1 parity rule, v2 storage keys, chunk layout, delete/truncate strategy. Read before touching either VFS.
- SQLite optimizations — brief tracker for SQLite cold-read, VFS, storage, preload, and benchmark optimization ideas.
- TLS trust roots — rustls native+webpki union rationale, which clients use which backend.
- Sleep sequence — engine lifecycle authority,
keepAwakevswaitUntilsemantics, grace deadline shutdown-token abort,can_arm_sleep_timervscan_finalize_sleeppredicates. Read before touching sleep/destroy lifecycle.
- Testing — running RivetKit tests, Vitest filter gotchas, driver-test parity workflow, Rust test layout.
- Feature flags — frontend
features.*system, deployment-flavor mapping, when to add a flag, consistency rules. - Build troubleshooting — DTS failures, NAPI rebuild,
JsActorConfigfield churn, tsup stale exports. - Docs sync — full table of "when you change X, update docs Y". Consult before finishing a change.
- Content frontmatter — required frontmatter schemas for docs + blog/changelog.
- Examples + Vercel — example templates, Vercel mirror regen, common errors.
- RivetError system — full derive example, artifact commit rule, anyhow usage.
- Dependencies — pnpm resolutions, Rust workspace deps, dynamic imports, version bumps, reqwest pool.
- When a
utoipa/ OpenAPI enum grows a new API variant, update the checked-in SDK unions underengine/sdks/{typescript,rust,go}/api-fullin the same change.engine/artifacts/openapi.jsoncan be ahead of the generated clients.