> ## Documentation Index
> Fetch the complete documentation index at: https://docs.base44.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Creating Actors

> Actors are Base44's realtime primitive: stateful server rooms over WebSockets. There is exactly one live instance per room id, and every client connected to that id shares it. The actor is authorit...

<Warning>
  This page is part of an AI coding agent skill and is written for agents, not humans. For the human-readable Base44 docs, see the [developer documentation](/developers).
</Warning>

# Creating Actors

Actors are Base44's realtime primitive: **stateful server rooms over WebSockets**. There is exactly one live instance per room id, and every client connected to that id shares it. The actor is authoritative — clients send inputs/operations, the actor validates them, applies them to its own state, and broadcasts the result.

Actors are defined locally in your project and deployed to the Base44 backend, just like backend functions.

Actors use direct WebSocket connections to Cloudflare. The SDK handles connection-token minting automatically; actors receive verified connection identity and can use `this.client.asServiceRole` for validated, room-owned work.

## When to Use an Actor

| Use an actor                                   | Use something else                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------------- |
| Multiplayer sessions where users interact live | Single-user state → entities                                              |
| Collaborative boards, docs, whiteboards        | A page that just lists records live → `base44.entities.Thing.subscribe()` |
| Presence and live cursors                      | Async or request/response work → backend functions                        |
| In-room chat                                   | Scheduled/background jobs → backend functions + automations               |
| Live auctions, countdowns, shared timers       | Work requiring app secrets or private data sources → backend functions    |

## Actor Directory

All actor definitions live in the `base44/actors/` folder. An actor is a folder containing an `entry.ts` file:

```
my-app/
  base44/
    actors/
      BoardRoom/
        entry.ts
      ChatRoom/
        entry.ts
```

## How to Create an Actor

1. Create a directory in `base44/actors/` named after the actor (PascalCase)
2. Create `entry.ts` in that directory and default-export a class extending `Actor`
3. Deploy it with `npx base44 actors deploy`

## Actor Discovery and Naming

The CLI discovers actors from `entry.ts` (or `entry.js`) files, and **the folder is the actor's identity** — the folder name becomes the actor name.

| File                               | Actor name  |
| ---------------------------------- | ----------- |
| `base44/actors/BoardRoom/entry.ts` | `BoardRoom` |
| `base44/actors/ChatRoom/entry.ts`  | `ChatRoom`  |

The name becomes the Durable Object class *and* the WebSocket connect handler, so it must be a plain JavaScript identifier:

**Rules:**

* Must match `[A-Za-z_][A-Za-z0-9_]*`, max 128 characters — **no `-`, `.`, `/`, or `:`**
* Must not be a JavaScript reserved word (`class`, `default`, `new`, `static`, …)
* Must be a **single folder level** — `base44/actors/games/Arena/entry.ts` is not a valid actor (unlike functions, actors cannot be nested)
* Must not collide with a backend function name
* Use **PascalCase** by convention (it reads as a class, and it is one)

| Valid         | Invalid       | Why                                      |
| ------------- | ------------- | ---------------------------------------- |
| `BoardRoom`   | `board-room`  | Hyphens are not valid in a JS identifier |
| `ChatRoom`    | `chat.room`   | Dots are not valid in a JS identifier    |
| `Lobby`       | `games/Arena` | Actors cannot be nested                  |
| `Room2`       | `2Room`       | Cannot start with a digit                |
| `AuctionRoom` | `class`       | Reserved word                            |

All `*.js`, `*.ts`, `*.json`, and `*.jsonc` files under the actor folder are included when deploying.

**Never name a helper `entry.ts`.** Every `entry.ts`/`entry.js` under `base44/actors/` is treated as an actor entry, at any depth — so `base44/actors/BoardRoom/lib/entry.ts` resolves to the nested name `BoardRoom/lib` and fails the "actors cannot be nested" check. The CLI's error names both causes (a genuinely nested actor, or a misnamed helper); rename the helper to anything else.

## Entry Point File

The entry file **default-exports** a class extending `Actor`, imported from `base44:runtime/actors` — the only import that resolves the base class:

```javascript theme={null}
// base44/actors/BoardRoom/entry.ts
import { Actor } from "base44:runtime/actors";

const MAX_USERS = 32;

export default class BoardRoom extends Actor {
  users = new Map();   // conn.id -> { seat, cursor }
  items = new Map();   // the shared, persisted room state
  nextSeat = 1;

  async handleStart() {
    // Runs on ANY wake (deploy, idle, hibernation). Rehydrate, then reconcile:
    // a hibernation wake keeps sockets ATTACHED without re-running handleConnect.
    this.items = new Map((await this.storage.get("items")) ?? []);
    this.users = new Map((await this.storage.get("seats")) ?? []);
    // Only a HIBERNATION wake still has sockets attached. A cold wake (deploy,
    // idle-out) has none — and the client that triggered it is not attached yet —
    // so pruning there would throw away every persisted seat.
    const live = new Set(this.getConnections().map((c) => c.id));
    if (live.size > 0) {
      for (const id of this.users.keys()) if (!live.has(id)) this.users.delete(id);
      await this.saveSeats();   // persist the prune, or the next wake re-reads the stale map
    }
    this.nextSeat = Math.max(0, ...[...this.users.values()].map((u) => u.seat)) + 1;
  }

  async handleConnect(conn) {
    // reject() closes the socket but does not return from the handler — return yourself.
    if (!this.users.has(conn.id) && this.users.size >= MAX_USERS) {
      conn.reject(4001, "room full");
      return;
    }
    // Reconnects are routine (network blips, reloads, redeploys): a returning
    // id reclaims its entry — never demote it or mint a new seat.
    if (!this.users.has(conn.id)) {
      this.users.set(conn.id, { seat: this.nextSeat++, cursor: null });
      await this.saveSeats();
    }
    conn.send({ type: "you", seat: this.users.get(conn.id).seat });
    conn.send({ type: "state", items: [...this.items.values()] });   // late joiners get full state
    this.broadcastPresence();
  }

  async handleMessage(conn, msg) {
    // Validate EVERYTHING at runtime: the payload is attacker-controlled and
    // msg can even be null. Accept operations, never authoritative state.
    if (typeof msg !== "object" || msg === null) return;
    const user = this.users.get(conn.id);
    if (!user) return;

    if (msg.type === "cursor") {
      user.cursor = [Number(msg.x) || 0, Number(msg.y) || 0];
      this.broadcastPresence();
    } else if (msg.type === "upsert_item" && typeof msg.id === "string" && msg.id.length <= 64) {
      const item = { id: msg.id, text: String(msg.text ?? "").slice(0, 2000) };
      this.items.set(item.id, item);
      await this.storage.put("items", [...this.items.entries()]);   // persist on change
      this.broadcast({ type: "item", item });
    }
  }

  async handleClose(conn) {
    this.users.delete(conn.id);
    await this.saveSeats();
    this.broadcastPresence();
  }

  saveSeats() {
    return this.storage.put("seats", [...this.users.entries()]);
  }

  broadcastPresence() {
    // Project an explicit public shape — never spread whole server objects into
    // a broadcast (they grow per-user secrets later).
    this.broadcast({
      type: "presence",
      users: [...this.users.values()].map((u) => ({ seat: u.seat, cursor: u.cursor })),
    });
  }
}
```

The class name is cosmetic — the deploy re-exports your default export under the **folder** name. `export default class extends Actor { … }` works too.

### Lifecycle Handlers

| Handler                    | When it runs                                                                                                                                                                                                                                                     |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `handleConnect(conn)`      | A client opened a connection to this room                                                                                                                                                                                                                        |
| `handleMessage(conn, msg)` | A client sent a message (parsed JSON)                                                                                                                                                                                                                            |
| `handleClose(conn)`        | A connection closed                                                                                                                                                                                                                                              |
| `handleStart()`            | Optional. Any time the instance wakes (deploy, idle-out, hibernation) — before any connection is handled. Rehydrate state here                                                                                                                                   |
| `handleWake(key)`          | Optional. A timer armed with `this.schedule(key, at)` came due                                                                                                                                                                                                   |
| `handleTick()`             | The managed ticker's callback — see [The Managed Ticker (Opt-In)](#the-managed-ticker-opt-in). Declare it even if you never opt in: it is an abstract member, so a **TypeScript** actor needs `handleTick() {}` to compile (plain-JavaScript actors can omit it) |

Never override `onStart` or `onAlarm` — those are platform plumbing.

### Instance API (`this.*`)

| Member                         | Description                                                                                |
| ------------------------------ | ------------------------------------------------------------------------------------------ |
| `this.broadcast(data)`         | Send a message to every connection in the room                                             |
| `this.getConnections()`        | Array of the live connections                                                              |
| `this.storage.get(key)`        | Read persisted state (`Promise<value \| undefined>`)                                       |
| `this.storage.put(key, value)` | Persist state                                                                              |
| `this.storage.delete(key)`     | Delete one key (`Promise<boolean>`)                                                        |
| `this.storage.deleteAll()`     | Wipe the room's storage — a later rejoin bootstraps like a brand-new room                  |
| `this.instanceId`              | This room's instance id (the value the client connected with)                              |
| `this.schedule(key, at)`       | Arm a one-shot wake at `at` (epoch ms or `Date`)                                           |
| `this.cancelSchedule(key)`     | Cancel a pending wake                                                                      |
| `this.client`                  | An anonymous Base44 SDK client (see [Calling Base44](#calling-base44-from-an-actor))       |
| `this.client.asServiceRole`    | Admin-level entity access, function calls, and integrations for validated, room-owned work |

### The Connection Object (`conn`)

| Member                      | Description                                                                                                                            |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `conn.id`                   | Per-connection identity, chosen by the client and reused across reconnects                                                             |
| `conn.identity`             | Platform-verified principal: `{ type: "authenticated", userId }` or `{ type: "anonymous", anonymousId }`. Preserved across hibernation |
| `conn.send(data)`           | Send a message to this one client                                                                                                      |
| `conn.reject(code, reason)` | Refuse the connection (closes the socket; **`return` immediately after**)                                                              |

Keep `conn.id` internal for seats and reconnect bookkeeping — never broadcast it or use it as proof of authorship or permissions. Publish a separate server-assigned seat or participant id for presence. For user attribution, check `conn.identity?.type === "authenticated"` and use `conn.identity.userId`; an anonymous visitor is not a signed-in user. Bind user-owned state and permissions to that verified identity, even when a reconnect reuses the same `conn.id`.

## State, Hibernation, and Reconnects

Instance fields (`this.users`, `this.items`, …) live only as long as the room is awake. A quiet room hibernates after \~10 seconds **even with clients still connected**; `this.storage` is what survives.

* Persist state you can't lose **when it changes**; never write high-frequency churn (every pointer move, every keystroke) to storage.
* Rehydrate in `handleStart()`, then **reconcile against `this.getConnections()`** — a hibernation wake keeps sockets attached and does **not** re-run `handleConnect`, so skipping this leaves every connected client unrecognized until it reconnects.
* Let a returning `conn.id` reclaim its entry (seat, role, score) instead of minting a new one.
* A reconnect that replaces a stale socket holding the same id closes the old one silently — `handleClose` does **not** fire for it, so the returning connection keeps its entry. Two **live** connections cannot share an id: the second is refused. That is why the client persists its connection id per tab (`sessionStorage`), never per browser.
* In sessions where a drop shouldn't instantly destroy state, give a missing id a short grace period; when the last client leaves mid-session, schedule the cleanup as a wake and cancel it if someone reconnects.

`static options = { hibernate: false }` only makes a room non-hibernatable, not resident — it is still evicted after a couple of minutes idle, so storage remains the only durable answer. It is rarely needed.

## Scheduled Wakes

```javascript theme={null}
await this.schedule("close_auction", Date.now() + 60_000);
// …later
await this.cancelSchedule("close_auction");

async handleWake(key) {
  if (key === "close_auction") {
    this.broadcast({ type: "auction_closed", winner: this.highBid });
  }
}
```

* Fires **even if the room is empty and asleep**.
* One-shot and coarse (±seconds); re-scheduling the same key overwrites it.
* Good for turn/forfeit timers, delayed cleanup of abandoned rooms, and absolute-time events. In-session countdowns should stay timestamp-driven on the client.

## The Managed Ticker (Opt-In)

`schedule()` handles *one* wake at an absolute time. When the room has to advance **on its own, repeatedly** — a game loop, a simulation step, a server-driven countdown — use the managed ticker instead.

You opt in by overriding `shouldTick()`. While it returns `true`, the platform calls `handleTick()` every `tickIntervalMs` (default `100`). When it returns `false` the ticker stops and the room is free to idle out as usual.

```javascript theme={null}
import { Actor } from "base44:runtime/actors";

export default class Match extends Actor {
  phase = "waiting";
  tickIntervalMs = 50;   // 20 fps; default is 100

  shouldTick() {
    return this.phase === "playing";   // cheap and side-effect free — it runs every tick
  }

  handleTick() {
    this.advance();                     // move the simulation forward
    this.broadcast({ type: "frame", state: this.publicState() });
    if (this.isOver()) this.phase = "done";   // ticker stops on the next check
  }

  handleMessage(conn, msg) {
    if (msg?.type === "start") this.phase = "playing";   // ticking resumes from here
  }
}
```

* **`shouldTick()` must be cheap and side-effect free.** It is consulted on every tick; do the work in `handleTick()`.
* **Don't write to `this.storage` every tick** — that is exactly the high-frequency churn to avoid. Persist at checkpoints (phase changes, round ends) and rehydrate in `handleStart()`; instance fields are still lost on a wake.
* **Don't re-arm the ticker from `handleTick()`** with `schedule()`. Flip the state that `shouldTick()` reads and let the platform manage the loop.
* A tick is not a delivery guarantee — clients can miss frames. Broadcast enough state to resynchronize, not just deltas.
* If the room only needs *one* future event, use [Scheduled Wakes](#scheduled-wakes); the ticker is for continuous advancement.

`handleTick()` is an abstract member of `Actor`, so a **TypeScript** actor must declare it even when it never opts in — `handleTick() {}` is enough. Plain-JavaScript actors can omit it.

## Broadcasting vs Per-Client Messages

* `this.broadcast(data)` — **room-wide state** everyone should see.
* `conn.send(data)` — events about **one** client (your seat, your hand, your error). Broadcasting these leaks private state and makes every client react.

Messages are JSON in both directions. `type` values beginning with `__` are reserved by the platform.

## Durable Results

When a session produces something that must outlive the room (the finished drawing, a chat transcript, an exported document):

1. The **actor** writes its validated result to `this.storage` and persists the canonical entity record through `this.client.asServiceRole`. For user attribution, store an explicit field from authenticated `conn.identity.userId`.
2. The actor broadcasts the result and re-`conn.send`s it to (re)connecting clients — a frontend cannot read actor storage. The frontend displays this result and writes only user-owned records, not the room's canonical result.

Make entity persistence **idempotent** using a stable key such as the room's instance id. Keep a pending result in actor storage until the entity write succeeds, and retry failed writes with a scheduled wake so persistence does not depend on a connected browser. Restrict canonical-result entity writes to the server; do not grant anonymous or frontend clients write access to make persistence work.

## Calling Base44 from an Actor

Every actor has `this.client`, a ready-made `@base44/sdk` client whose ordinary calls use the app's **anonymous** role. Backend functions can be invoked anonymously, so use `this.client.functions.invoke(...)`. Actors also have `this.client.asServiceRole` for admin-level entity access and integrations:

```javascript theme={null}
const rows = await this.client.asServiceRole.entities.Room.filter({ status: "open" });

const res = await this.client.functions.invoke("settle_auction", { roomId: this.instanceId });
const settled = res.data;   // invoke() returns the raw response; the JSON is on .data
```

* Ordinary entity access is RLS-gated like a logged-out visitor; `asServiceRole` bypasses RLS. Validate client input and permissions before privileged calls, and use them for work the room owns: canonical results, registry rows, or private configuration.
* Both clients use production entity data and call the function version pinned to the actor's deployment.
* Neither client impersonates a signed-in user. Attribute service-role records with an explicit field from authenticated `conn.identity.userId`; never take that field from the message payload.
* The runtime exchanges and caches the service token automatically. The first privileged call after a wake incurs an exchange, so keep privileged calls on persistence paths rather than on every message or tick.

## Using Secrets

Actors **do not receive app secrets or private data-source bindings**. Put secret-dependent operations in a backend function that reads the secret and performs the operation there. For example, define `fetch_market_price` to use `MARKET_API_KEY` and return the price, then call it from the actor:

```javascript theme={null}
const res = await this.client.functions.invoke("fetch_market_price", { symbol: "ACME" });
const price = res.data;   // the function returns the result, never the secret
```

The platform provisions `ACTOR_TOKEN_SECRET` automatically on the first actor deploy. Do not ask the user to create it or overwrite it during setup: changing it rotates the actors' keys. It is not exposed to actor or function code. Actor runtime configuration and derived signing keys are platform-managed.

## Multi-File Actors

An actor is not limited to `entry.ts`. Any `.js`, `.ts`, `.json`, or `.jsonc` file inside the actor's folder is uploaded on deploy and can be imported with a relative path:

```
base44/
  actors/
    BoardRoom/
      entry.ts       ← import { sanitize } from "./sanitize.ts";
      sanitize.ts
      limits.json
```

**The actor's own folder is the whole upload.** `base44 actors deploy` sends exactly the files under `base44/actors/<Name>/` — unlike `functions deploy`, which also uploads the `base44/shared/` tree alongside every function. Keep the code an actor imports inside the actor's folder (copy it, or expose it through a backend function the actor calls with `this.client`).

## Rooms and Discovery

One actor instance = one session (one board, one match, one auction). Never funnel every user into a single global room.

* **Cap capacity in `handleConnect`** and `conn.reject(...)` past the limit — the actor is the only place a cap can be enforced.
* **Browsable rooms:** keep a registry **entity** (e.g. `Room` with `status`, `user_count`) whose **record id is the actor instance id**. The registry is advertising; the actor is truth. List rooms by subscribing to the entity first, then fetching and reconciling by id, and filter to recently-updated rows (crashed rooms leave stale ones behind).
* **Private rooms:** there is no room-level auth. The instance id *is* the admission control, so mint it with `crypto.randomUUID()` (record ids are enumerable), keep it out of any readable registry, and share it only as an invite link or code.
* Instance ids are printable ASCII, 1–256 characters, and may not contain `/`.

## Deploying Actors

```bash theme={null}
npx base44 actors deploy          # all actors
npx base44 actors delete Lobby    # tear one down on the server
```

Actors are also deployed as part of `npx base44 deploy`. For details, see [actors-deploy.md](https://docs.base44.com/developers/skills/base44-cli/references/actors-deploy.md).

Deleting the folder does **not** remove a deployed actor — the next deploy simply stops including it while the old one keeps serving. Run `actors delete` to tear it down.

## Notes

* Actors run on the Cloudflare backend; deploying one activates it if needed.
* Actors serve only the realtime WebSocket path — **automations are not supported** on an actor. Use a backend function if you need scheduled or entity-triggered work.
* `base44 dev` does not run actors locally; verify against a deployed actor.
* Use `npm:` specifiers for npm packages (e.g. `npm:zod`), same as in backend functions.
* Connecting from the frontend is `base44.actors.<Name>(instanceId).connect()` — see the base44-sdk skill's [actors.md](https://docs.base44.com/developers/skills/base44-cli/references/../../base44-sdk/references/actors.md).

## Common Mistakes

| Wrong                                              | Correct                                                | Why                                                                                                       |
| -------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `base44/functions/ChatRoom/entry.ts` with `Actor`  | `base44/actors/ChatRoom/entry.ts`                      | Actors have exactly one home; the actor import is rejected in the functions bucket                        |
| `import { Actor } from "@base44/sdk"`              | `import { Actor } from "base44:runtime/actors"`        | Only the virtual module resolves the base class at deploy time                                            |
| `base44/actors/chat-room/entry.ts`                 | `base44/actors/ChatRoom/entry.ts`                      | The name becomes a JS class binding — no hyphens                                                          |
| `base44/actors/games/Arena/entry.ts`               | `base44/actors/Arena/entry.ts`                         | Actors cannot be nested                                                                                   |
| A helper at `base44/actors/BoardRoom/lib/entry.ts` | `base44/actors/BoardRoom/lib/helper.ts`                | Every `entry.ts` under the actors dir is an actor entry, so this reads as the nested name `BoardRoom/lib` |
| `export class ChatRoom extends Actor` only         | `export default class ChatRoom extends Actor`          | The deploy re-exports the **default** export                                                              |
| A TypeScript actor with no `handleTick`            | Add `handleTick() {}`                                  | It is an abstract member of `Actor`; the file will not compile without it                                 |
| Deleting the folder to remove a deployed actor     | `npx base44 actors delete <Name>`                      | Removing the source only stops future deploys; the live actor keeps serving                               |
| `import { ok } from "../../shared/util.ts"`        | Keep the helper inside the actor folder                | Only the actor's own folder is uploaded                                                                   |
| Storing state only in instance fields              | `this.storage.put(...)` + rehydrate in `handleStart()` | Instance fields are lost when the room hibernates                                                         |
| `this.broadcast({ type: "your_hand", cards })`     | `conn.send({ type: "your_hand", cards })`              | Per-client events must not be broadcast                                                                   |
| Trusting `msg.score` from a client                 | Recompute the outcome in the actor                     | Clients send inputs; the actor is authoritative                                                           |
