Skip to main content

Before you start

Install the Terse CLI, then create a project:
The finished code for this guide lives in the bank account example.

Defining an actor

Each bank account is a durable actor. An actor has fields and methods. The @Persisted property decorator saves a field each time you assign it. To track a bank balance, define src/actor.ts:

Generating a client

Start the local dev server from the project:
Wait for Ready, then generate the client in a second terminal:
This creates a generated folder with typed stubs for your actors. Each actor instance is addressed by an ID. Create a client.ts that gets the demo account and calls a method:
Run it with bun client.ts. The generated client connects to the local dev server on port 7100. An actor starts when one of its methods is called, stays in memory while it is busy, and shuts down after sitting idle. Regenerate the client after changing an actor’s methods.

Ephemeral fields

Next, count the deposits made since the actor last woke up. The @Ephemeral decorator keeps a field in memory only while the actor is awake. It resets when the actor shuts down.

WebSocket messages

Actors handle WebSocket connections directly. The three type parameters of Actor describe each connection. Payloads are JSON.
  • Metadata identifies the client. In this example, it is the userId passed when the client connects.
  • Incoming messages are what a client can send. Here, { type: "ping" }, which onMessage answers with the balance.
  • Outgoing messages are what the actor sends, through either socket.send or broadcast. Here, { type: "balance", balance }.
The example also uses:
  • Tags. setTags labels one socket. onConnect tags each socket "customer".
  • Automatic responses. The gateway answers a raw text ping with pong without waking the actor. onConnect registers the pair. A JSON { type: "ping" } still runs onMessage.
  • Broadcasts. broadcast sends to every connected socket. deposit limits it to sockets tagged "customer", and onDisconnect uses except to skip the socket that just left.
  • Individual sends. socket.send sends to one socket. onConnect and onMessage use it to send that client the current balance.

Emitting state

@Emittable publishes a public @Persisted field to every connected client, so balance becomes public:
  • When a client connects, it receives a state message with the current emitted fields, here message.state.balance.
  • After a successful call changes the field, clients receive a state_update message with the changes.
To connect, your backend prepares a WebSocket grant for the actor, and the client opens its websocketUrl. A real backend checks the user’s access before issuing a grant; the chatroom example shows a browser doing this. Open the connection before the grant’s connectByMs deadline. Authorization is checked on connection; an accepted socket remains authorized until it closes, including while the actor sleeps. Reconnects require a valid grant. To revoke an existing connection, explicitly close it.

Using @Resource

It’s common to tie a durable actor to other pieces of infrastructure, such as a sandbox or a coding harness. Those have a different lifecycle; for example, a sandbox can stay up for days. To keep your actor’s core logic simple, create resources and tie them to the actor’s lifecycle. First, create your resource. The Pi example has the complete version:
Then link it to your actor:

Scheduling an alarm

Each actor has one alarm. Setting it again replaces the previous timestamp. When the timestamp is due, the runtime wakes the actor and runs onAlarm.

Interleaving calls

An actor runs one call at a time, and a call holds the actor until it finishes, including across an await. @Interleave lets other calls run while that method awaits. Add a second actor for wire transfers to src/actor.ts:
getStatus can run while submit waits on the clearing network. Once any method in a class uses @Interleave, this.db.transaction throws in that class, because overlapping calls may already have used the changes.

Using SQLite

Each actor has its own SQLite database at this.db. this.db.exec(sql, ...bindings) runs one statement and returns its rows. Here, deposit records each deposit in a ledger table:
  • Each SQL write and each @Persisted assignment is saved as it happens, so progress survives a crash mid-method. If the method throws, writes made before the error stay saved.
  • Use await this.db.transaction(async () => { ... }) to save a group of SQL and @Persisted writes together when it succeeds. A throwing transaction rolls back those writes and rethrows; the method can catch the error and continue. Transactions nest. This replaces the removed @Atomic decorator.
  • Assigning a @Persisted field writes its JSON value through the same SQL execution path as this.db.exec().
  • Reassign objects and arrays to persist changes: this.messages = [...this.messages, message]. Nested mutations such as this.messages.push(message) only change memory; they are not saved, even at method completion. Assign undefined to remove a saved field value rather than using delete.
  • Saved fields are restored before persistence tracking begins. Defaults are inserted only for fields missing from SQLite, so adding a persisted property initializes existing actors without replacing their saved values. Initialization does not delete existing rows.
  • Bind values with ? placeholders. Values can be strings, numbers, bigints, byte arrays, or null.
  • Actor constructors must accept the opaque ActorContext and pass it to super(context) before using this. Classes without a constructor forward the context automatically.
  • this.db and this.id are available after super(context) in constructors and in field initializers, methods, and socket hooks. SQLite is restored before construction; @Persisted fields are hydrated afterward.
  • Keep constructor and field-initializer SQL synchronous and repeatable (for example, CREATE TABLE IF NOT EXISTS): both run again when actors are restored. Their writes commit with the next successful method or socket hook; a throwing constructor or initializer rolls them back.
  • The runtime owns transactions and the database file, so BEGIN, COMMIT, END, ROLLBACK, SAVEPOINT, RELEASE, ATTACH, DETACH, and VACUUM are rejected. Use this.db.transaction for a nested transaction.
  • The only allowed PRAGMAs are table_info, table_xinfo, index_info, index_xinfo, index_list, foreign_key_list, foreign_key_check, integrity_check, quick_check, and user_version.
  • Names beginning with __terse_ or _litestream_ are reserved.

Configuring actor resources

@Compute overrides the deployment’s default resources for one actor class:
cpu and memoryMiB are requests; cpuLimit and memoryLimitMiB cap them and must be at least the request. An omitted limit inherits the server default, which may leave CPU uncapped, and is raised to fit your request. The server rejects deployments outside its configured range. Actors without @Compute resources start on prewarmed sandboxes; other sizes start a new sandbox.

Using Unchecked<>

A lot of the types found in pi-durable, the Vercel AI SDK, and similar libraries are serializable, but their type information is very complex. You can opt out of type generation for these types so your generated client marks them as unknown. This is how pi-durable suggests getting its op messages across the wire.
And then on your client:
This only impacts the client generation process. You still get full type safety within the actor!