website/src/content/posts/2026-06-17-introducing-the-rust-sdk/page.mdx
Today we're releasing the RivetKit Rust SDK (beta): a native, typed Rust SDK for Rivet Actors.
{/* Mermaid measures each node label's foreignObject width using its default
sans font, but the docs body font (JetBrains Mono) is wider, so the rendered
label overflows the measured foreignObject and its overflow: hidden clips the
text. Forcing the diagram labels back to a normal sans stack makes the rendered
width match Mermaid's measurement so every label fits inside its node box. The
overflow/centering rules are a belt-and-suspenders fallback so any residual
width difference spills symmetrically inside the already-wide node rect instead
of clipping on the right. */}
You can already write the actor pattern in plain async Rust: a Tokio task that owns state behind a channel is a lightweight actor. One owner, message passing, no locks.
// State lives in the task; reach it over a channel.
let (tx, mut rx) = mpsc::channel(32);
tokio::spawn(async move {
let mut count = 0;
while let Some(amount) = rx.recv().await {
count += amount;
}
});
It's fearless concurrency, but it never leaves the process. The handle works in-process only, and the in-memory state dies with the task.
flowchart LR
caller["Caller"]
subgraph proc["Single Machine"]
t1["Actor A"]
t2["Actor B"]
end
caller -->|"messages"| t1
caller -->|"messages"| t2
classDef envoy fill:#bfdbfe,stroke:#3b82f6,color:#000
class t1,t2 envoy
A Rivet Actor keeps that exact shape but makes it stateful and distributed (the virtual actor pattern):
That same architecture, distributed across machines by Rivet, looks like this:
flowchart LR
caller["Caller"]
subgraph m1["Machine 1"]
a1["Actor A"]
a2["Actor B"]
end
subgraph m2["Machine 2"]
a3["Actor C"]
a4["Actor D"]
end
caller -->|"messages"| a1
caller -->|"messages"| a2
caller -->|"messages"| a3
caller -->|"messages"| a4
a1 --> storage[("Durable Storage")]
a2 --> storage
a3 --> storage
a4 --> storage
classDef envoy fill:#bfdbfe,stroke:#3b82f6,color:#000
classDef storageNode fill:#fecaca,stroke:#ef4444,color:#000
class a1,a2,a3,a4 envoy
class storage storageNode
See the crash course for an introduction to Actors.
The smallest useful actor: typed actions, persisted state, and an event broadcast to every connected client.
use std::{future::Future, pin::Pin, sync::Arc};
use async_trait::async_trait;
use rivetkit::prelude::*;
use serde::{Deserialize, Serialize};
pub const ACTOR_NAME: &str = "counter";
pub struct Counter;
// Persisted state: serialized, validated, and restored on wake.
#[derive(Default, Serialize, Deserialize)]
pub struct CounterState {
pub count: i64,
}
// A typed action with an explicit payload and `Output`.
#[derive(Debug, Serialize, Deserialize)]
pub struct Increment {
pub amount: i64,
}
impl Action for Increment {
type Output = i64;
const NAME: &'static str = "increment";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetCount;
impl Action for GetCount {
type Output = i64;
const NAME: &'static str = "getCount";
}
// A typed event broadcast to every connected client.
#[derive(Debug, Serialize, Deserialize)]
pub struct NewCount {
pub count: i64,
}
impl Event for NewCount {
const NAME: &'static str = "newCount";
}
#[async_trait]
impl Actor for Counter {
type State = CounterState;
type Input = ();
type Actions = (Increment, GetCount);
type Events = (NewCount,);
type Queue = ();
type ConnParams = ();
type ConnState = ();
type Action = action::Raw;
async fn create_state(_ctx: &Ctx<Self>, _input: Self::Input) -> Result<Self::State> {
Ok(CounterState::default())
}
async fn create(_ctx: &Ctx<Self>) -> Result<Self> {
Ok(Self)
}
}
impl Handles<Increment> for Counter {
type Future = BoxFuture<i64>;
fn handle(self: Arc<Self>, ctx: Ctx<Self>, action: Increment) -> Self::Future {
Box::pin(async move {
// In-memory read-modify-write; the change is persisted for you.
let count = {
let mut state = ctx.state_mut();
state.count += action.amount;
state.count
};
// Broadcast the new value to every connected client.
ctx.emit(NewCount { count })?;
Ok(count)
})
}
}
impl Handles<GetCount> for Counter {
type Future = BoxFuture<i64>;
fn handle(self: Arc<Self>, ctx: Ctx<Self>, _action: GetCount) -> Self::Future {
Box::pin(async move { Ok(ctx.state().count) })
}
}
pub fn registry() -> Registry {
let mut registry = Registry::new();
registry.register_actor::<Counter>(ACTOR_NAME);
registry
}
type BoxFuture<T> = Pin<Box<dyn Future<Output = Result<T>> + Send>>;
The same shape, plus embedded SQLite for durable history and a plain struct field for ephemeral state. This is the chat-room-rust example.
use std::time::{SystemTime, UNIX_EPOCH};
use std::{future::Future, pin::Pin, sync::Arc};
use async_trait::async_trait;
use rivetkit::prelude::*;
use rivetkit::{BindParam, ColumnValue};
use serde::{Deserialize, Serialize};
pub const ACTOR_NAME: &str = "chatRoom";
pub struct ChatRoom {
started_at_ms: i64,
}
#[derive(Default, Serialize, Deserialize)]
pub struct ChatRoomState {
pub sent_count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Message {
pub sender: String,
pub text: String,
pub timestamp: i64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SendMessage {
pub sender: String,
pub text: String,
}
impl Action for SendMessage {
type Output = Message;
const NAME: &'static str = "sendMessage";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetHistory;
impl Action for GetHistory {
type Output = Vec<Message>;
const NAME: &'static str = "getHistory";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetStats;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomStats {
pub sent_count: u64,
pub started_at_ms: i64,
}
impl Action for GetStats {
type Output = RoomStats;
const NAME: &'static str = "getStats";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NewMessage {
pub message: Message,
}
impl Event for NewMessage {
const NAME: &'static str = "newMessage";
}
#[async_trait]
impl Actor for ChatRoom {
type State = ChatRoomState;
type Input = ();
type Actions = (SendMessage, GetHistory, GetStats);
type Events = (NewMessage,);
type Queue = ();
type ConnParams = ();
type ConnState = ();
type Action = action::Raw;
const HAS_DATABASE: bool = true;
async fn create_state(_ctx: &Ctx<Self>, _input: Self::Input) -> Result<Self::State> {
Ok(ChatRoomState::default())
}
async fn create(ctx: &Ctx<Self>) -> Result<Self> {
ctx.sql()
.execute(
"CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
text TEXT NOT NULL,
timestamp INTEGER NOT NULL
)",
None,
)
.await?;
Ok(Self {
started_at_ms: now_ms(),
})
}
}
impl Handles<SendMessage> for ChatRoom {
type Future = BoxFuture<Message>;
fn handle(self: Arc<Self>, ctx: Ctx<Self>, action: SendMessage) -> Self::Future {
Box::pin(async move {
let message = send_message(&ctx, action.sender, action.text).await?;
ctx.state_mut().sent_count += 1;
ctx.emit(NewMessage {
message: message.clone(),
})?;
Ok(message)
})
}
}
impl Handles<GetHistory> for ChatRoom {
type Future = BoxFuture<Vec<Message>>;
fn handle(self: Arc<Self>, ctx: Ctx<Self>, _action: GetHistory) -> Self::Future {
Box::pin(async move { get_history(&ctx).await })
}
}
impl Handles<GetStats> for ChatRoom {
type Future = BoxFuture<RoomStats>;
fn handle(self: Arc<Self>, ctx: Ctx<Self>, _action: GetStats) -> Self::Future {
Box::pin(async move {
Ok(RoomStats {
sent_count: ctx.state().sent_count,
started_at_ms: self.started_at_ms,
})
})
}
}
pub fn registry() -> Registry {
let mut registry = Registry::new();
registry.register_actor::<ChatRoom>(ACTOR_NAME);
registry
}
async fn send_message(ctx: &Ctx<ChatRoom>, sender: String, text: String) -> Result<Message> {
let timestamp = now_ms();
ctx.sql()
.execute(
"INSERT INTO messages (sender, text, timestamp) VALUES (?, ?, ?)",
Some(vec![
BindParam::Text(sender.clone()),
BindParam::Text(text.clone()),
BindParam::Integer(timestamp),
]),
)
.await?;
Ok(Message {
sender,
text,
timestamp,
})
}
async fn get_history(ctx: &Ctx<ChatRoom>) -> Result<Vec<Message>> {
let result = ctx
.sql()
.query(
"SELECT sender, text, timestamp FROM messages ORDER BY id ASC",
None,
)
.await?;
let mut messages = Vec::with_capacity(result.rows.len());
for row in result.rows {
messages.push(Message {
sender: column_text(row.first())?,
text: column_text(row.get(1))?,
timestamp: column_int(row.get(2))?,
});
}
Ok(messages)
}
fn column_text(value: Option<&ColumnValue>) -> Result<String> {
match value {
Some(ColumnValue::Text(text)) => Ok(text.clone()),
other => Err(anyhow!("expected text column, got {other:?}")),
}
}
fn column_int(value: Option<&ColumnValue>) -> Result<i64> {
match value {
Some(ColumnValue::Integer(int)) => Ok(*int),
other => Err(anyhow!("expected integer column, got {other:?}")),
}
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or_default()
}
type BoxFuture<T> = Pin<Box<dyn Future<Output = Result<T>> + Send>>;
Every actor includes:
Serialize / Deserialize types. No bespoke schema layer to learn.impl blocks. No build step and no actor macros.tokio-console.RivetKit isn't one isolated runtime per language. Every SDK talks to the same engine and resolves actors by name, so languages mix freely on both sides of a call.
rivetkit-core, so each language gets the same semantics. The Rust SDK calls it directly with no bridge and zero FFI overhead.See the deployment guides for the full platform list.
cargo add rivetkit
hello-world-rust and chat-room-rust