# Durable actor concepts
Source: https://docs.useterse.ai/faqs
Durable actors, use cases, state, and execution.
## What is a durable actor?
A durable actor is a serverless stateful object.
An actor is a small backend for a piece of state you want to protect. It keeps state and compute in one place.
## How is an actor identified?
Each actor instance has an ID. Its project, class, and ID identify it globally. You can reach the same actor from anywhere.
Keep the same ID across reconnects and restarts. Use a new ID for a new conversation or run.
## Who manages the actor hosts?
Terse manages scaling and placement of actor hosts. Successful calls save persisted state. Hosts can scale down. The next call restores that state.
## When should I use a durable actor?
* AI agents
* Human approvals
* Collaborative documents
* Chat rooms
* Shared queues and rate limits
## When should I use something else?
Use a stateless handler for independent requests. Use a database for cross-entity queries and analytics. Use a batch system for large data scans.
Actor calls do not create a transaction across several actors. Coordinate cross-actor work explicitly.
## How should I model a durable actor?
Think of an actor as your smallest piece of atomic state. Keep related values and their updates together.
* **Conversation:** one actor for its messages and current turn.
* **Document:** one actor for its content and revision number.
## How long does a durable actor stay up before cleanup?
The runtime's default idle timeout is **10 seconds**. Deployments and actor resource settings can override it.
Method calls and WebSocket messages reset activity. Running handlers defer eviction. An idle instance can leave memory; saved state remains.
Open WebSockets keep the host and connections alive. The actor instance can still be evicted. Without open sockets, the idle host shuts down. A later call restores the actor from saved state.
Idle cleanup does not delete actor state. Store approvals in persisted fields. Let the handler return during the wait.
## What resource isolation does each durable actor get?
Each hosted actor runs in an isolated sandbox with CPU and memory limits. The underlying machines are shared.
Runtime defaults are **0.25 CPU** and **256 MiB RAM** per actor.
## Why are durable actors sequential by default? How do I disable that?
Mark a method as reentrant to allow overlap:
```ts theme={null}
import { Actor, Interleave } from "terse-sdk/actor"
export class Worker extends Actor {
@Interleave
async wait(milliseconds: number): Promise {
await new Promise(resolve => setTimeout(resolve, milliseconds))
return this.id
}
}
```
```python theme={null}
import time
from durable_actors import Actor, interleave
class Worker(Actor):
@interleave
def wait(self, seconds: float) -> str:
time.sleep(seconds)
return self.id
```
Reentrancy lets later calls enter before the marked method finishes. Ordinary calls still serialize with each other. A running ordinary call still blocks new entries. Mark the actual entry point; a decorated helper does not change its caller's policy.
TypeScript calls interleave across asynchronous work. Python calls overlap on worker threads.
# Durable actors quickstart
Source: https://docs.useterse.ai/quickstart
Build a realtime chat application on durable actors, in TypeScript or Python.
## 1. Create the actor project
To follow this tutorial you need:
* Node.js 22.19 or later
* A Terse [account](https://app.useterse.ai)
* An OpenAI API key
```sh theme={null}
npx terse-cli install
```
Choose a language.
```sh theme={null}
terse init agent-chat
cd agent-chat
npm install ai@^6 @ai-sdk/openai@^3
```
```sh theme={null}
terse init agent-chat --language python
cd agent-chat
uv add openai
```
Create `.env` in `agent-chat/`. Add your provider key:
```dotenv .env theme={null}
OPENAI_API_KEY=your-openai-api-key
```
## 2. Define the actor
```ts src/actor.ts theme={null}
import { openai } from "@ai-sdk/openai"
import { streamText } from "ai"
import { Actor, Emittable, Persisted, type ActorSocket } from "terse-sdk/actor"
type Member = { name: string }
type Message = { role: "user" | "assistant"; content: string }
export class ChatHistory extends Actor {
// Save history and sync it to connected clients.
@Persisted @Emittable messages: Message[] = []
async onMessage(socket: ActorSocket, text: string): Promise {
// Add the user's message to the conversation.
const messages: Message[] = [
...this.messages,
{ role: "user", content: `${socket.metadata.name}: ${text}` },
]
const reply: Message = { role: "assistant", content: "" }
this.broadcast([...messages, reply])
// Stream the growing reply to every connected client.
const result = streamText({ model: openai("gpt-5-mini"), messages })
for await (const chunk of result.textStream) {
reply.content += chunk
this.broadcast([...messages, reply])
}
// Save the completed conversation when this handler returns.
this.messages = [...messages, reply]
}
}
```
```python src/actor.py theme={null}
from typing import Literal
from durable_actors import Actor, ActorSocket, emitted
from openai import OpenAI
from openai.types.responses import ResponseInputParam
from pydantic import BaseModel
class Member(BaseModel):
name: str
class Message(BaseModel):
role: Literal["user", "assistant"]
content: str
class ChatHistory(Actor[Member, str, list[Message]]):
# Save history and sync it to connected clients.
messages: list[Message] = emitted(default_factory=list)
def on_message(self, socket: ActorSocket[Member, list[Message]], text: str) -> None:
# Add the user's message to the conversation.
messages = [
*self.messages,
Message(role="user", content=f"{socket.metadata.name}: {text}"),
]
reply = Message(role="assistant", content="")
self.broadcast([*messages, reply])
# Stream the growing reply to every connected client.
inputs: ResponseInputParam = [{"role": m.role, "content": m.content} for m in messages]
with OpenAI() as client:
with client.responses.stream(model="gpt-5-mini", input=inputs) as stream:
for event in stream:
if event.type == "response.output_text.delta":
reply.content += event.delta
self.broadcast([*messages, reply])
# Save the completed conversation when this handler returns.
self.messages = [*messages, reply]
```
## 3. Run locally and generate the client
Start the actor server:
```sh theme={null}
terse actor dev
```
Keep it running. Open a second terminal in `agent-chat/`. Generate the backend client:
```sh theme={null}
terse actor generate --language typescript --out-dir generated
```
```sh theme={null}
terse actor generate --language python --out-dir generated
```
Generated code points to [http://127.0.0.1:7100/v1/projects/local/actors](http://127.0.0.1:7100/v1/projects/local/actors) by default. Regenerate after making changes to actor code.
## 4. Add the application backend
```sh theme={null}
npm install express
npm install --save-dev @types/express
```
```ts app.ts theme={null}
import express from "express"
import { actors } from "./generated/index.js"
const app = express()
// Issue a socket grant for this conversation.
app.post("/socket", async (_req, res) => {
const grant = await actors.ChatHistory.prepareWebsocket({
actorId: "demo-chat",
metadata: { name: "Local developer" },
})
res.set("Cache-Control", "no-store").json(grant)
})
app.listen(3000, "127.0.0.1")
```
```sh theme={null}
uv add flask
```
```python app.py theme={null}
from flask import Flask, Response, jsonify
from generated import ActorProxy, actors
app = Flask(__name__)
@app.post("/socket")
def connect() -> Response:
# Issue a socket grant for this conversation.
grant = ActorProxy.handle(actors.ChatHistory.Authorization(
actor_id="demo-chat",
metadata=actors.ChatHistory.Metadata(name="Local developer"),
))
response = jsonify(websocketUrl=grant.websocket_url)
response.headers["Cache-Control"] = "no-store"
return response
```
## 5. Connect the browser
```sh theme={null}
npm create vite@latest frontend -- --template react
cd frontend
npm install
cd ..
```
```js frontend/vite.config.js theme={null}
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
export default defineConfig({
plugins: [react()],
server: {
proxy: { "/socket": "http://127.0.0.1:3000" },
},
})
```
```jsx frontend/src/App.jsx theme={null}
import { useEffect, useRef, useState } from "react"
export default function App() {
const socket = useRef(null)
const [messages, setMessages] = useState([])
const [text, setText] = useState("")
const [busy, setBusy] = useState(true)
useEffect(() => {
let connection
let cancelled = false
// Get a grant from the backend. Connect to the actor.
async function connect() {
const response = await fetch("/socket", { method: "POST" })
const grant = await response.json()
if (cancelled) return
connection = new WebSocket(grant.websocketUrl)
socket.current = connection
connection.onmessage = event => {
const data = JSON.parse(event.data)
if (Array.isArray(data)) {
// Replace the displayed reply as it streams.
setMessages(data)
setBusy(true)
} else {
// Restore saved history on connection and after each reply.
if (data.type === "state") setMessages(data.state.messages)
if (data.type === "state_update") setMessages(data.changes.messages)
setBusy(false)
}
}
}
connect()
// Close the socket when this component unmounts.
return () => {
cancelled = true
connection?.close()
}
}, [])
// Send the user's text to the actor.
function send(event) {
event.preventDefault()
socket.current.send(JSON.stringify(text))
setText("")
setBusy(true)
}
return (
)
}
```
## 6. Try streaming and persistence
Start the app:
```sh theme={null}
bun app.ts
```
```sh theme={null}
uv run --env-file .env flask --app app run --port 3000
```
Open a third terminal in `agent-chat/`. Start React:
```sh theme={null}
cd frontend
npm run dev
```
## 7. Deploy to Terse
From the actor project:
```sh theme={null}
terse secrets add OPENAI_API_KEY
terse deploy
```
Open [the Terse app](https://app.useterse.ai). Open your profile menu. Select **API tokens**. Create a token and copy it.
### Test the deployed actor locally
Keep React and your backend on your machine. Connect them to the actor running on Terse.
Add these values to `agent-chat/.env`. Use the URL from `terse deploy` and your Terse API token:
```dotenv theme={null}
TERSE_ACTOR_URL=https://api.useterse.ai/YOUR_PROJECT_ID/actors
TERSE_API_KEY=your-terse-api-key
```
Regenerate the client and restart the backend from `agent-chat/`:
```sh theme={null}
terse actor generate --language typescript --out-dir generated
bun app.ts
```
```sh theme={null}
terse actor generate --language python --out-dir generated
uv run --env-file .env flask --app app run --port 3000
```
Open [http://localhost:5173](http://localhost:5173) to use the app.
# Durable actors CLI reference
Source: https://docs.useterse.ai/reference/cli
Create, run, connect, and deploy durable actors.
## Install
Requires Node.js 22.19 or later.
```sh theme={null}
npx terse-cli install
terse --help
```
## Create a project
```sh theme={null}
terse init my-agent
terse init my-python-agent --language python
```
TypeScript starts in `src/actor.ts`. Python starts in `src/actor.py`. `terse.config.json` identifies the hosted project.
For unattended setup, authenticate first:
```sh theme={null}
terse init my-agent --non-interactive --yes
```
## Run locally
Run from the actor project:
```sh theme={null}
terse actor dev
```
The server watches your source. The default endpoint is `http://127.0.0.1:7100/v1/projects/local/actors`. Local state lives in `.durable-actors/`. Keep that directory to retain state.
| Option | Default | Purpose |
| - | - | - |
| `--port ` | `7100` | Set the local server port. |
| `--project-id ` | `local` | Set the local project ID. |
## Generate a client
Keep the actor server running. Run from your application project:
```sh theme={null}
terse actor generate --language typescript
terse actor generate --language python
```
| Option | Purpose |
| - | - |
| `--language typescript\|python` | Select the client language. Omit to infer from the contract. |
| `--out-dir ` | Set the generated directory. |
| `--yes` | Approve missing Python prerequisites and dependencies. |
| `--non-interactive` | Disable prompts. |
TypeScript defaults to `src/terse.actors/`. Python defaults to `generated/`. Python generation requires a `pyproject.toml` with a `[project]` table. TypeScript generation requires a compatible `terse-sdk` in the application.
Generate TypeScript clients from TypeScript actors. Python actors currently expose Python client contracts. Use the matching language for the quickstart.
Regenerate after changing public methods, state, or socket types. Keep generated files unchanged.
## Deploy
Run from the actor project:
```sh theme={null}
terse deploy
```
The CLI checks the actor contract and uploads source. Python definitions undergo strict mypy checks. TypeScript definitions undergo type checking. The CLI prints the hosted actor URL.
Set that URL in the calling application. Set its `TERSE_API_KEY`. Regenerate the client against the hosted contract.
## Secrets
```sh theme={null}
terse secrets add OPENAI_API_KEY
terse secrets list
terse secrets import .env
terse secrets remove OPENAI_API_KEY
```
`add` prompts for the value. `--value-stdin` reads it from standard input. Local sync writes the value to `.env`; `--skip-local-sync` disables that write. `import --overwrite` replaces existing server values. `list --json` lists names.
Keep provider keys in the actor project. Keep Terse credentials in the calling backend. Keep both out of browser code.
## Connection settings
| Variable | Consumer | Purpose |
| - | - | - |
| `TERSE_ACTOR_URL` | Client and generator | Full actor endpoint. Defaults to the local URL above. |
| `TERSE_API_KEY` | Client and CLI | Credential for the selected Terse backend or protected runtime. |
| `TERSE_ACTOR_PORT` | Local server | Port override. `--port` takes precedence. |
| `TERSE_ACTOR_PROJECT_ID` | Local server | Project override. `--project-id` takes precedence. |
| `TERSE_ACTOR_SECRET` | Local server | Enable local authentication. Set the same client `TERSE_API_KEY`. |
| `DURABLE_ACTORS_DATA_DIR` | Local server | Override local state storage. |
| `DURABLE_ACTORS_PYTHON` | Python tooling | Select the Python interpreter. |
Create an API token in the app under your profile menu → **API tokens**. Copy it into the backend's `TERSE_API_KEY`.
The CLI loads `.env`. Application backends must load their own environment. Saved CLI credentials can authorize hosted client generation; running applications still need `TERSE_API_KEY`.
## Account and utilities
| Command | Purpose |
| - | - |
| `terse auth login` | Sign in. |
| `terse auth logout` | Clear saved credentials. |
| `terse auth status` | Show authentication status. |
| `terse auth org list` | List organizations. |
| `terse auth org switch [org-id]` | Select an organization. |
| `terse target status` | Show the selected backend. |
| `terse target use [preset]` | Select backend and frontend targets. |
| `terse target clear` | Clear target overrides. |
| `terse update` | Update the CLI. |
| `terse dashboard` | Open the app. |
| `terse docs` | Open the docs. |
| `terse completion install` | Install shell completion. |
| `terse completion uninstall` | Remove shell completion. |
Run `terse --help` for command options.
# Durable actors Python SDK
Source: https://docs.useterse.ai/reference/python-sdk
Define actors, generate typed clients, and stream output.
## Install
Requires Python 3.11 or later. The Terse CLI also requires Node.js.
```sh theme={null}
uv add durable-actors
uv add --dev 'durable-actors[codegen]'
```
Define actors in `src/actor.py`:
```python theme={null}
from durable_actors import Actor, ActorSocket, emitted
from pydantic import BaseModel
class Member(BaseModel):
user_id: str
class Change(BaseModel):
by: int
class Update(BaseModel):
count: int
class Counter(Actor[Member, Change, Update]):
count: int = emitted(0)
def increment(self, by: int) -> int:
self.count += by
return self.count
def on_message(self, socket: ActorSocket[Member, Update], message: Change) -> None:
self.increment(message.by)
self.broadcast(Update(count=self.count))
```
## Actor definitions
Extend `Actor` directly. Use typed synchronous `def` methods. Use defaults or factories for fields. Prefix helper methods with `_`.
| API | Purpose |
| - | - |
| `Actor[Metadata, Incoming, Outgoing, Tag]` | Type connection metadata, messages, and optional tags. |
| Annotated field with a default | Persist state after successful calls. |
| `emitted(default)` | Persist a field and emit saved state. |
| `emitted(default_factory=list)` | Create a separate mutable default per actor. |
| `ephemeral(default)` | Keep a field in memory only. |
| `ephemeral(default_factory=...)` | Create a temporary client, lock, or cache. |
| `@interleave` | Allow overlapping calls on worker threads. |
| `self.id` | Read the bound actor ID during a call. |
Use concrete public types. JSON primitives, typed collections, unions, and Pydantic models work. `Any` and untyped collections fail contract generation.
## Backend calls
Run from a Python application with `pyproject.toml`:
```sh theme={null}
terse actor generate --language python
```
```python theme={null}
from generated import actors
counter = actors.Counter.get("visits")
count = counter.increment(1)
```
Generated calls are synchronous. Use a worker thread from an async web framework.
The namespace exposes `actors.Counter.Stub`, `actors.Counter.State`, message models, and method argument and result types.
Set `TERSE_ACTOR_URL` and `TERSE_API_KEY` on the calling backend for hosted actors. Load `.env` explicitly; `uv run --env-file .env` can do that.
## Browser access
Authenticate the user. Check access to the actor ID. Derive metadata on your backend. Issue a grant:
```python theme={null}
from generated import ActorProxy, actors
grant = ActorProxy.handle(actors.Counter.Authorization(
actor_id="visits",
metadata=actors.Counter.Metadata(user_id="authorized-user"),
))
print(grant.websocket_url)
```
Return the URL with `Cache-Control: no-store`. The browser opens a WebSocket. It sends JSON messages such as `{"by": 1}`.
## Socket handlers
| API | Purpose |
| - | - |
| `on_connect(socket)` | Accept a connection or reject it. |
| `on_message(socket, message)` | Handle a validated application message. |
| `on_disconnect(socket, code, reason, was_clean)` | Handle disconnection. A process failure can skip this hook. |
| `socket.metadata` | Read backend-supplied metadata. |
| `socket.send(message)` | Send to one connection. |
| `socket.reject(4003, "Forbidden")` | Reject during `on_connect`. |
| `socket.close(1000, "Done")` | Close the connection. |
| `socket.set_tags(*tags)` | Set connection tags. |
| `self.get_connections()` | List connections during a call. |
| `self.broadcast(message)` | Send to all connections, including the sender. |
Broadcast filters accept `except_ids`, `tags`, and `tag_match="all"` or `"any"`.
## Streaming and subscriptions
Use the provider's synchronous stream inside the handler. Broadcast each output update. Save the completed result before returning.
Browser clients receive `state` snapshots and `state_update` messages for emitted fields. Replace snapshots. Merge updates. Remove fields listed in `removed`.
Python clients can subscribe to complete typed state:
```python theme={null}
with counter.subscribe(lambda state: print(state.count)):
counter.increment(1)
input("Press Enter to close.")
```
Subscription callbacks run on a background thread. Errors stop the subscription and reach `on_error`.
For application messages, use `counter.connect(actors.Counter.Metadata(user_id="authorized-user"))` as a context manager. Call `connection.send(...)`. Iterate over the connection. Handle `StateSnapshot` and `StateUpdate` alongside application messages.
Broadcasts have no replay. Reconnect and restore saved state. See the [quickstart](/quickstart).
## Failures and concurrency
Ordinary calls serialize. Failed calls roll back persisted fields. External writes and broadcasts can already have happened.
`ActorInvocationError` with code `outcome_unknown` means the call may have executed. Reconcile before retrying.
`@interleave` allows concurrent worker threads. It disables failure rollback across the actor class. Protect shared mutations with an ephemeral lock. See [Concepts](/faqs#why-are-durable-actors-sequential-by-default-how-do-i-disable-that).
# Durable actors TypeScript SDK
Source: https://docs.useterse.ai/reference/typescript-sdk
Define actors, persist state, and stream output.
## Install
```sh theme={null}
npm install terse-sdk zod
```
Define actors in `src/actor.ts`. Use standard TypeScript decorators; leave `experimentalDecorators` disabled. Keep the configuration from `terse init`.
```ts theme={null}
import { Actor, Emittable, Persisted, type ActorSocket } from "terse-sdk/actor"
type Member = { userId: string }
type Change = { by: number }
type Update = { count: number }
export class Counter extends Actor {
@Persisted
@Emittable
count = 0
async increment(by: number): Promise {
this.count += by
return this.count
}
async onMessage(socket: ActorSocket, message: Change): Promise {
await this.increment(message.by)
this.broadcast({ count: this.count })
}
}
```
## Actor definitions
Import actor classes, decorators, and socket types from `terse-sdk/actor`.
Extend `Actor` directly. Give fields defaults. Keep constructors free of external work. Make application methods `async`. Use concrete JSON-compatible argument and return types.
| API | Purpose |
| - | - |
| `Actor` | Type connection metadata, messages, and optional tags. |
| `@Persisted` | Save a field after successful calls. |
| `@Ephemeral` | Keep a field in memory only. |
| `@Emittable` | Send saved field snapshots and updates. Requires public `@Persisted`. |
| `@Interleave` | Allow other calls to enter during this method. |
| `this.id` | Read the bound actor ID during a call. |
Every instance field needs `@Persisted` or `@Ephemeral`. Private persisted fields stay outside emitted state. Use TypeScript `private`; JavaScript `#private` fields cannot use `@Persisted`.
## Backend calls
Generate the client from your application project:
```sh theme={null}
terse actor generate --language typescript
```
```ts theme={null}
import { actors } from "./terse.actors/index.js"
const counter = actors.Counter.get("visits")
const count = await counter.increment(1)
```
`get()` returns a reference. The first call activates the actor as needed. Use the same ID to reach the same state.
Use the generated client for Terse connection settings. Set `TERSE_ACTOR_URL` and `TERSE_API_KEY` on the calling backend for hosted actors.
## Browser access
Authenticate the user. Check access to the actor ID. Derive metadata on your backend. Issue a grant:
```ts theme={null}
const grant = await actors.Counter.prepareWebsocket({
actorId: "visits",
metadata: { userId: "authorized-user" },
})
```
Return the grant with `Cache-Control: no-store`. The browser opens `new WebSocket(grant.websocketUrl)`. It sends `JSON.stringify({ by: 1 })`.
`ActorProxy.handle({ actorName, actorId, metadata })` provides the same grant flow through the generated `ActorProxy` export.
## Socket handlers
| API | Purpose |
| - | - |
| `onConnect(socket)` | Accept a connection or reject it. |
| `onMessage(socket, message)` | Handle a parsed application message. |
| `onDisconnect(socket, code, reason, wasClean)` | Handle disconnection. A process failure can skip this hook. |
| `socket.metadata` | Read backend-supplied metadata. Assign the whole value to update it. |
| `socket.send(message)` | Send to one connection. |
| `socket.reject(4003, "Forbidden")` | Reject during `onConnect`. |
| `socket.close(1000, "Done")` | Close the connection. |
| `socket.setTags(...tags)` | Set connection tags. |
| `this.getConnections()` | List connections during a call. Returns a promise. |
| `this.broadcast(message, options?)` | Send to matching connections. Includes the sender by default. |
Broadcast options accept `except`, `tags`, and `tagMatch: "all" | "any"`. Socket metadata and tags last for that connection.
## Streaming and state
Broadcast inside the model's streaming loop. Accumulate output locally. Assign the completed result to a persisted field before returning.
Emitted fields use two wire messages:
```json theme={null}
{"type":"state","state":{"count":0}}
{"type":"state_update","changes":{"count":1},"removed":[],"version":1}
```
Replace local state on `state`. Merge `changes` on `state_update`. Delete fields in `removed`. Keep application message types distinct from those two names.
Broadcasts have no replay. Obtain a fresh grant on reconnect. Restore the saved snapshot. See the [browser example](/quickstart#5-connect-the-browser).
## Failures and concurrency
Ordinary calls serialize. Failed calls roll back persisted fields. Broadcasts and external writes can already have happened.
`ActorInvocationError` with code `outcome_unknown` means the call may have executed. Reconcile saved state and external effects before retrying.
`@Interleave` permits overlap. It disables failure rollback across the actor class. See [Concepts](/faqs#why-are-durable-actors-sequential-by-default-how-do-i-disable-that).