Before you start
Install the Terse CLI, then create a project: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:Ready, then generate the client in a second terminal:
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:
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 ofActor describe each connection. Payloads are JSON.
- Metadata identifies the client. In this example, it is the
userIdpassed when the client connects. - Incoming messages are what a client can send. Here,
{ type: "ping" }, whichonMessageanswers with the balance. - Outgoing messages are what the actor sends, through either
socket.sendorbroadcast. Here,{ type: "balance", balance }.
- Tags.
setTagslabels one socket.onConnecttags each socket"customer". - Automatic responses. The gateway answers a raw text
pingwithpongwithout waking the actor.onConnectregisters the pair. A JSON{ type: "ping" }still runsonMessage. - Broadcasts.
broadcastsends to every connected socket.depositlimits it to sockets tagged"customer", andonDisconnectusesexceptto skip the socket that just left. - Individual sends.
socket.sendsends to one socket.onConnectandonMessageuse 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
statemessage with the current emitted fields, heremessage.state.balance. - After a successful call changes the field, clients receive a
state_updatemessage with the changes.
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: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 runsonAlarm.
Interleaving calls
An actor runs one call at a time, and a call holds the actor until it finishes, including across anawait. @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 atthis.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
@Persistedassignment 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@Persistedwrites 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@Atomicdecorator. - Assigning a
@Persistedfield writes its JSON value through the same SQL execution path asthis.db.exec(). - Reassign objects and arrays to persist changes:
this.messages = [...this.messages, message]. Nested mutations such asthis.messages.push(message)only change memory; they are not saved, even at method completion. Assignundefinedto remove a saved field value rather than usingdelete. - 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, ornull. - Actor constructors must accept the opaque
ActorContextand pass it tosuper(context)before usingthis. Classes without a constructor forward the context automatically. this.dbandthis.idare available aftersuper(context)in constructors and in field initializers, methods, and socket hooks. SQLite is restored before construction;@Persistedfields 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, andVACUUMare rejected. Usethis.db.transactionfor 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, anduser_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.

