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

# Stateful Actors (Beta) llms-full.txt

> Complete machine-readable documentation content for Stateful Actors (Beta) (Compute) for AI agents and LLMs

# Telnyx Compute: Stateful Actors (Beta) — Full Documentation

> Complete page content for Stateful Actors (Beta) (Compute section) of the Telnyx developer docs ([https://developers.telnyx.com](https://developers.telnyx.com)).
> This file: [https://developers.telnyx.com/docs/development/llms/compute-stateful-actors-beta-llms-full-txt.md](https://developers.telnyx.com/docs/development/llms/compute-stateful-actors-beta-llms-full-txt.md) · Root index: [https://developers.telnyx.com/llms.txt](https://developers.telnyx.com/llms.txt)

## Overview

### Stateful Actors

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors.md)

A **stateful actor** is a single-threaded server that owns the state of one entity — one
user, one cart, one call leg, one chat room. You write it as a TypeScript class; the
platform runs **one instance per name**, routes every call for that name to that
instance, runs your methods **one at a time**, and **persists** what you write. You never
bind a socket, take a lock, or shard it.

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Account extends StatefulActor {
  async debit(amount: number): Promise<{ ok: boolean; balance: number }> {
    const balance = (await this.ctx.storage.get<number>("balance")) ?? 0;
    if (balance < amount) return { ok: false, balance };   // check-then-act, no lock
    await this.ctx.storage.put("balance", balance - amount);
    return { ok: true, balance: balance - amount };
  }
}
```

That `debit` is a read-modify-write with no lock and no transaction. In a normal request
handler that's a race; here it isn't — and that property is the whole product.
[How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works) derives it from
first principles, starting from a plain C server.

Two words used precisely throughout these docs: the **actor** is the class you ship
(`Account`); an **instance** is its per-name materialization (`idFromName("acct_123")`
reaches the `acct_123` instance of `Account`).

## What the Runtime Guarantees

* **One instance per name** — `idFromName("acct_123")` routes every call to a single owner; no two hosts run it at once.
* **One call at a time** — single-threaded dispatch. No locks, no races inside an instance.
* **Durable before reply** — a method's result isn't returned until the writes it made are persisted.
* **Memory is a cache** — only what you write to `storage` survives eviction or restart.

[Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model) states
each precisely; [How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works)
derives them from a plain C server.

Scaffold, deploy, and curl an actor.
The C-vs-actor derivation from first principles.
The four guarantees, stated precisely.
Where instances run; what survives a restart.
Naming and routing instances.
The two-export shape.
Reach one actor from many functions.
And when to reach for something else.
The full surface.
Scheduled work from inside an actor.
Terminate a live socket on the actor — each frame is a serialized turn.

## Related Resources

* [Bindings](/docs/edge-compute/runtime/bindings) — how bindings resolve on `env`
* [KV](/docs/edge-compute/kv) — globally distributed key-value storage
* [Execution Model](/docs/edge-compute/runtime/execution-model) — function lifecycle and concurrency

***

## Get Started

### Stateful Actors Quick Start

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/quick-start.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/quick-start.md)

Ship an `Account` actor end-to-end: scaffold, write the class, deploy, and curl it. Each account name is its own actor instance — `alice` and `bob` are isolated, each with their own balance, all persisted. Roughly 10 minutes.

## What You'll Build

A function with routes that all share a single durable `Account` actor type — but each account name is its own isolated instance:

```
POST /accounts/:id/deposit   { "amount": 100 }  → { account, balance }
POST /accounts/:id/debit     { "amount": 30 }   → { account, ok, balance }
GET  /accounts/:id/balance                       → { account, balance }
GET  /health/{liveness,readiness}                → "ok"
```

A `debit` never overdraws, and a committed debit is never lost.

## Why an Account

An `Account` exercises the three properties a Stateful Actor gives you:

* **Per-instance isolation** — `alice` and `bob` are separate actor instances with their own balance.
* **Single-threaded dispatch** — concurrent debits on the same account serialize automatically. No lock, no race: two debits can't both pass the balance check.
* **Persistence** — the balance survives across requests; it's durable and reloaded when the actor is next invoked.

## Prerequisites

* The `telnyx-edge` CLI, installed and authenticated — a release with Stateful Actor
  deploy support. **v0.2.2 and earlier cannot ship an actor project** (`new-func --actor`
  appears to work, but `ship` fails); upgrade from the
  [releases page](https://github.com/team-telnyx/edge-compute/releases).
* A Telnyx API key.
* Node.js (for `npm`).

## 1. Authenticate

```bash theme={null}
telnyx-edge auth api-key set <YOUR_API_KEY>
telnyx-edge auth status
```

## 2. Scaffold the Actor Project

```bash theme={null}
telnyx-edge new-func --actor --name=account
cd account
npm install
```

This writes an umbrella `telnyx.toml` (with a `[[actors]]` block), a sample actor class, a `fetch` handler, and registers the function (writes a `func_id` into `telnyx.toml`). `npm install` pulls in `@telnyx/edge-runtime`.

## 3. Write the Account Actor

Replace the scaffolded actor class with `src/account.ts`. It holds one value — the balance — in `this.ctx.storage`:

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Account extends StatefulActor {
  /** Add funds. Returns the new balance. */
  async deposit(amount: number): Promise<{ balance: number }> {
    const balance = (await this.ctx.storage.get<number>("balance")) ?? 0;
    const next = balance + amount;
    await this.ctx.storage.put("balance", next);
    return { balance: next };
  }

  /** Subtract funds if sufficient. Atomic — single-threaded dispatch means
   *  two debits on the same account can't both pass the check. */
  async debit(amount: number): Promise<{ ok: boolean; balance: number }> {
    const balance = (await this.ctx.storage.get<number>("balance")) ?? 0;
    if (balance < amount) return { ok: false, balance }; // insufficient funds
    const next = balance - amount;
    await this.ctx.storage.put("balance", next);
    return { ok: true, balance: next };
  }

  /** Read the current balance. */
  async balance(): Promise<{ balance: number }> {
    return { balance: (await this.ctx.storage.get<number>("balance")) ?? 0 };
  }
}
```

## 4. Write the Fetch Handler

Replace `src/index.ts` with a handler that routes HTTP to actor method calls. It re-exports the actor class (so it ships with the bundle) and calls it through the `ACCOUNT` binding on `env`:

```ts theme={null}
// Re-export the actor class from the entry point so it is bundled and shipped
// with the function. The exported class name must equal the [[actors]] type.
export { Account } from "./account";
import type { Account } from "./account";

import {
  type ActorNamespace,
  type ActorStub,
  type IdFromNameOptions,
} from "@telnyx/edge-runtime";

type AccountStub = ActorStub & Pick<Account, "deposit" | "debit" | "balance">;
interface AccountNamespace extends ActorNamespace {
  idFromName(name: string, options?: IdFromNameOptions): AccountStub;
}
interface Env {
  ACCOUNT: AccountNamespace;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);

    if (url.pathname === "/health/liveness") return new Response("ok");
    if (url.pathname === "/health/readiness") return new Response("ok");

    const m = url.pathname.match(/^\/accounts\/([^/]+)\/(deposit|debit|balance)$/);
    if (!m) return new Response("not found", { status: 404 });

    const id = m[1]!;
    const action = m[2]!;
    const stub = env.ACCOUNT.idFromName(id);

    if (action === "deposit" && req.method === "POST") {
      const body = (await req.json().catch(() => ({}))) as { amount?: number };
      if (typeof body.amount !== "number")
        return Response.json({ error: "amount (number) required" }, { status: 400 });
      const result = await stub.deposit(body.amount);
      return Response.json({ account: id, ...result });
    }

    if (action === "debit" && req.method === "POST") {
      const body = (await req.json().catch(() => ({}))) as { amount?: number };
      if (typeof body.amount !== "number")
        return Response.json({ error: "amount (number) required" }, { status: 400 });
      const result = await stub.debit(body.amount);
      return Response.json({ account: id, ...result });
    }

    if (action === "balance" && req.method === "GET") {
      const result = await stub.balance();
      return Response.json({ account: id, ...result });
    }

    return new Response("not found", { status: 404 });
  },
};
```

## 5. Update `telnyx.toml`

Point the `[[actors]]` binding at your class. The scaffold generated a sample binding; change it to `ACCOUNT` → `Account`:

```toml theme={null}
name = "account"
main = "src/index.ts"
compatibility_date = "2026-05-01"

[[actors]]
binding = "ACCOUNT"   # the property on env — your handle
type    = "Account"   # the class to instantiate per account name

[edge_compute]
func_id = "..."       # created by new-func; leave as-is
func_name = "account"
```

## 6. Deploy

```bash theme={null}
telnyx-edge ship
```

`ship` bundles the project, uploads it, and monitors the deploy — wait for
`✅ Func 'account' is now deployed!` (in `telnyx-edge list`, the function's status
becomes `deploy_ok`).

## 7. Hit the URL

`ship` prints a URL like `account-&lt;id>.telnyxcompute.com`. The function may take a moment to come up — poll the health endpoint until it returns 200:

```bash theme={null}
B=https://account-<id>.telnyxcompute.com

# wait for it to come up
curl -sS --retry 30 --retry-delay 5 --retry-connrefused "$B/health/liveness"
```

Now exercise it — two accounts, no overdraw, isolation, persistence:

```bash theme={null}
# alice deposits 100, then debits 30
curl -sS -X POST $B/accounts/alice/deposit -H 'content-type: application/json' -d '{"amount":100}'
# → {"account":"alice","balance":100}
curl -sS -X POST $B/accounts/alice/debit   -H 'content-type: application/json' -d '{"amount":30}'
# → {"account":"alice","ok":true,"balance":70}

# an overdraw is rejected — balance unchanged
curl -sS -X POST $B/accounts/alice/debit   -H 'content-type: application/json' -d '{"amount":1000}'
# → {"account":"alice","ok":false,"balance":70}

# bob is a separate instance with its own balance
curl -sS -X POST $B/accounts/bob/deposit   -H 'content-type: application/json' -d '{"amount":5}'
# → {"account":"bob","balance":5}

# isolation + persistence: alice is still 70, bob is 5
curl -sS $B/accounts/alice/balance   # → {"account":"alice","balance":70}
curl -sS $B/accounts/bob/balance     # → {"account":"bob","balance":5}
```

You should see:

* `alice` ends at 70 (100 − 30; the 1000 debit was rejected)
* `bob` is independent at 5
* `alice` is unchanged by `bob`'s activity (isolation), and survives across requests (persistence)

## Next Steps

* [Shared Actors](/docs/edge-compute/stateful-actors/shared-actors) — add a second function that reads/writes the **same** `Account` through a different binding
* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `StatefulActor`, `ctx`, `storage`, alarms
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — schedule deferred work from inside an actor method

***

## Concepts

### How It Works

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/how-it-works.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/how-it-works.md)

Take a concrete problem: **keep a balance per account, reachable over HTTP, correct
when requests overlap, and intact across restarts.** A `debit` must never overdraw,
and a committed debit must never be lost. Below is what that actually requires, from
first principles, and how a stateful actor hands you the same thing.

## How You'd Build This, from First Principles

Strip it to the metal. One process serves **every** account — `a`, `b`, `c`, `x`, `y`,
… — holding them all in an in-memory map and handling a request for whichever one comes
in. Two things make each `debit` correct: a **per-account lock**, so two debits on the
same account can't interleave (while debits on *different* accounts still run in
parallel), and a **write-ahead log + `fsync`**, so a committed debit is on disk before
you reply.

```c theme={null}
struct account {
    int64_t         balance;
    pthread_mutex_t lock;     // serializes debits on THIS account; other accounts run in parallel
};
// one process, every account: a map of account_id -> struct account *
struct account *lookup_or_create(const char *acct_id);
```

Durability is append-to-log then `fsync` — you don't acknowledge the debit until the
bytes are on disk:

```c theme={null}
void persist_data(const char *acct_id, int64_t balance) {
    dprintf(wal_fd, "%s %lld\n", acct_id, (long long)balance);  // append one record to the log
    fsync(wal_fd);                                              // the durability boundary: on disk before we return
}
```

A request names an account; the process looks it up and serves it. Lock that account,
check-and-decrement, log, `fsync`, unlock:

```c theme={null}
// One process, any account. Error handling elided.
int debit(const char *acct_id, int64_t amount, int64_t *out) {
    struct account *a = lookup_or_create(acct_id);  // a, b, c, x, y ... all live in this process

    pthread_mutex_lock(&a->lock);              // serialize debits on THIS account
    if (a->balance < amount) {
        pthread_mutex_unlock(&a->lock);
        return -EAGAIN;                         // insufficient funds
    }
    a->balance -= amount;
    persist_data(acct_id, a->balance);          // append a record + fsync: durable before we return

    *out = a->balance;
    pthread_mutex_unlock(&a->lock);
    return 0;
}
```

Two primitives do all the work: the **lock** is the serialization point, and **`fsync`
before you reply** is durability. Everything else follows:

* one process owns it all — the map, the per-account locks, and the log are shared by
  every account `a`…`y`;
* **durability** is the log: a debit is committed once its record is `fsync`'d, so a
  crash loses only what was never acked;
* **recovery** is *not* replaying an ever-growing log from zero — that gets slower
  forever. You periodically write a **snapshot** of all balances and keep only the log
  records since it; on restart you load the snapshot and replay that short tail.
  (Postgres' checkpoint + WAL, SQLite's WAL mode, and Redis' RDB + AOF all do exactly
  this.) The on-disk snapshot + log is the truth; memory is a cache;
* to grow past one process you **shard** accounts across processes and machines,
  routing each account to the one that owns it — and *this* is where most people stop
  hand-rolling and reach for a **database**,
  which is exactly this machine (sharded, replicated, snapshot + write-ahead log) run by
  someone else. Postgres' `COMMIT` is what `persist_data` does, its checkpoint is your
  snapshot, and `SELECT … FOR UPDATE` is the lock.

You can build all of this. The work isn't any single line — it's that you own the lock
discipline on every path, the log format, the snapshots, the recovery, the sharding, and
the restarts, forever.

## The Same Thing as a Stateful Actor

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Account extends StatefulActor {
  async debit(amount: number): Promise<{ ok: boolean; balance: number }> {
    const balance = (await this.ctx.storage.get<number>("balance")) ?? 0;
    if (balance < amount) return { ok: false, balance };   // check-then-act, no lock
    await this.ctx.storage.put("balance", balance - amount);
    return { ok: true, balance: balance - amount };
  }
}
```

The class is only the logic half. In the C version, `debit(acct_id, …)` did the lookup
itself (`lookup_or_create`) *and* the logic. With an actor that lookup moves to the
**caller**: a normal function parses the request, names the account with `idFromName`,
and calls the method — which now takes only `amount` and never has to find its own state.

```ts theme={null}
import { type ActorNamespace, type ActorStub } from "@telnyx/edge-runtime";
import { Account } from "./account";
export { Account };

type AccountStub = ActorStub & Pick<Account, "debit">;
interface AccountNamespace extends ActorNamespace {
  idFromName(name: string): AccountStub;
}
interface Env { ACCOUNT: AccountNamespace }

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const acct = new URL(req.url).pathname.slice(1);     // /acct_123 — the account name
    const { amount } = (await req.json()) as { amount: number };
    const result = await env.ACCOUNT.idFromName(acct).debit(amount);  // route by name, then call
    return Response.json(result);
  },
};
```

`idFromName(acct)` is the actor-world `lookup_or_create` — instead of finding a struct in
this process's map, it routes to the one instance that owns that account, anywhere (the
"Reaching one account" row below).

Same check-and-decrement, same durability requirement — but no `pthread_mutex`, no
`fsync`, no log, no snapshots, no shard map. The two primitives are still there; the
runtime provides them. Here's the mapping, mechanism by mechanism:

| Mechanism (first principles) | Hand-rolled in C                                                           | Stateful actor                                                         |
| ---------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Serialization point          | a `pthread_mutex` per account, held across the change                      | one thread per name — the lock you can't forget, because there is none |
| Durable before you reply     | `persist_data` — append + `fsync` — then ack                               | method returns ⇒ writes flushed (flush-before-ack)                     |
| Truth vs cache               | the snapshot + log on disk; the in-memory map is a cache                   | `ctx.storage`; instance fields are a cache, gone on restart            |
| Reaching one account         | a map lookup in-process; a shard map + router once you outgrow one process | `idFromName(name)` routes to the one owner                             |
| Parallelism across accounts  | per-account locks in one multi-threaded process                            | a separate instance per name — each its own thread and `storage`       |
| Restart recovery             | load the snapshot, replay the log tail                                     | runtime restarts the instance; `storage` is already durable            |
| Scheduling                   | a `timerfd` + a timer wheel                                                | `ctx.storage.setAlarm` + an `alarm()` handler                          |

The runtime takes the one process that multiplexed `a`, `b`, `c`, `x`, `y` and splits
it: each account id becomes its own instance. `idFromName("a")` and `idFromName("b")`
are two different instances, on two different threads, each with its own `storage` —
debits on `a` serialize, debits on `a` and `b` run in parallel, and there is no shared
map, per-account lock, or single log to own. A stateful actor *is* that machine — a
single-threaded server with durable, flush-before-ack storage, one per account — that
the platform shards, routes, and restarts for you. You write the body of `debit`; the
runtime is the lock, the durability, the recovery, and the router.

The four guarantees the runtime makes from this mapping are stated precisely in
[Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model).

## Next Steps

* [Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model) — the four guarantees, stated precisely
* [Lifecycle & placement](/docs/edge-compute/stateful-actors/concepts/lifecycle) — where an instance runs, and what survives a restart
* [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) — naming and routing instances
* [Project structure](/docs/edge-compute/stateful-actors/guides/project-structure) — the two-export shape

***

### Execution Model

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/execution-model.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/execution-model.md)

The runtime makes four guarantees. Each is the managed form of a mechanism you'd
otherwise build by hand — the [C server and mapping table in How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works)
show where each one comes from. Stated precisely:

**1. Exactly one instance per name.**
`idFromName("acct_123")` routes every call for that name to one owner — the shard map
and router you'd otherwise build by hand, done for you. No two hosts run account
`acct_123` at once, so the instance is the source of truth for that account.

**2. One call at a time — no concurrency inside an instance.**
This is the per-account `pthread_mutex`, except you don't write it and can't forget it.
One thread per instance runs each method to completion before the next starts. `await`
yields the thread, but the next call still won't start until your method returns. The C
`debit` is correct *because* it holds the lock across the change; the actor `debit` is
correct because two debits on one account never run at the same time.

**3. Writes are durable before the caller sees a result.**
This is `write()` + `fsync()` before you reply. When your method returns, the runtime
holds the response until every write you issued has been flushed — the caller can't
observe a result built on an unflushed write. (If a write rejects after the method
returns, the call fails with `ActorOutputGateError` instead of returning success.)

**4. Only persisted state survives; memory is a cache.**
This is "the on-disk snapshot + log is the truth; the in-memory map is a cache." An
instance can be evicted or restarted between any two calls — a deploy, idle eviction,
host failure — and instance fields (`this.x`) vanish while `ctx.storage` does not. The
runtime does the snapshotting and recovery; you just read from `storage`. Treat memory
exactly like a process that can be `SIGKILL`ed at any instant: only what you flushed to
`storage` is real.

## Next Steps

* [How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works) — the C-vs-actor derivation behind these guarantees
* [Lifecycle & placement](/docs/edge-compute/stateful-actors/concepts/lifecycle) — how eviction and restart play out (guarantee 4)
* [When to use](/docs/edge-compute/stateful-actors/guides/when-to-use) — the throughput limits these guarantees imply

***

### Lifecycle & Placement

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/lifecycle.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/lifecycle.md)

A stateful actor instance is activated on demand, kept in memory while it's busy,
evicted when idle, and can be restarted or relocated at any time. You don't manage any
of this — but you design around it, because **only what you write to `storage` survives.**

## Where it Runs

An instance is placed on **first touch** — the first method call to a name brings it
up. After that you address it by name, never by location; the platform routes every
call to wherever the instance currently lives. (`idFromName` accepts a `locationHint`
option, reserved for regional placement — see
[Actor Namespace](/docs/edge-compute/stateful-actors/api-reference/namespace).)

## Activation and Eviction

* **Activated on demand.** Naming an instance is cheap; the first method call to a name
  brings the instance up and routes the call to it.
* **Kept warm while busy, evicted when idle.** Between calls, an idle instance may be
  removed from memory to free resources, then re-activated on the next call.
* **Restarted on deploy or host failure.** A new code version or a host issue tears the
  instance down and re-creates it.

## What Survives a Restart

In-memory state — instance fields like `this.x` — is a **cache**. It vanishes on
eviction, restart, or relocation. Your actor's `storage` is the **truth**: it is durable
and reloaded when the instance next activates. Treat memory exactly like a process that
can be `SIGKILL`ed at any instant — only what you flushed to `storage` is real. (This
is guarantee 4 in [Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model).)

A common pattern is to load from `storage` once on activation and cache the result in
memory; just never assume the cache is still there.

## Next Steps

* [How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works) — the execution model and its guarantees
* [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) — naming and routing instances
* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `ctx.storage`, `blockConcurrencyWhile`

***

### Addressing

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/addressing.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/concepts/addressing.md)

`env.&lt;BINDING>` is your handle to an actor class. You name an instance, get a **stub**,
and call methods on it.

## Two Ways to Name an Instance

```ts theme={null}
env.ACCOUNT.idFromName("acct_123") // deterministic — your shard key; same name, same instance
env.ACCOUNT.newUniqueId()          // random — mints a fresh, unguessable identity
```

* **`idFromName(name)`** is deterministic: the same `name` always lands on the same
  instance, from anywhere. This is the common case — pick a stable identity for the
  entity and let the platform route to its state.
* **`newUniqueId()`** mints a fresh, unguessable instance; you store the id
  (`stub.id`) yourself. It currently fails at call time on the platform (a known
  runtime issue — see [Actor Namespace](/docs/edge-compute/stateful-actors/api-reference/namespace));
  until it's fixed, mint an identity yourself and pass it to `idFromName`.

## Choosing a Name

The name *is* your shard key — choose the entity's natural identity: a caller's E.164
number, a user or account id, a room id, or a composite key (`tenant:room`). Same name →
same single-threaded instance → coherent state for that entity. Spreading load means
choosing names that spread across entities; a single hot name is one single-threaded
bottleneck (see [When to use](/docs/edge-compute/stateful-actors/guides/when-to-use)).

## Calling the Stub

Both calls return a **stub**. Calling a method on the stub is an RPC to that one
instance: the call is queued there, your method runs, the value comes back.

## Next Steps

* [How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works) — why one-instance-per-name gives coherent state
* [Project structure](/docs/edge-compute/stateful-actors/guides/project-structure) — declaring the binding in `telnyx.toml`
* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `ActorNamespace` and `ActorStub`

***

## Reference

### Overview

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference.md)

The types in this reference are exported from `@telnyx/edge-runtime` (TypeScript).

The Stateful Actor surface at a glance — follow a link for the full page:

| Surface                                                                         | Where it lives                    | What it's for                                                                     |
| ------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
| [`StatefulActor&lt;E>`](/docs/edge-compute/stateful-actors/api-reference/base)  | `@telnyx/edge-runtime`            | Base class you extend. Holds `this.ctx` and `this.env`.                           |
| [`ActorContext`](/docs/edge-compute/stateful-actors/api-reference/context)      | `this.ctx` (runtime-injected)     | Per-instance identity, storage, single alarm, one-shot init.                      |
| [`ActorStorage`](/docs/edge-compute/stateful-actors/api-reference/storage)      | `this.ctx.storage`                | Per-key persistent storage.                                                       |
| [`ActorNamespace`](/docs/edge-compute/stateful-actors/api-reference/namespace)  | `env.&lt;BINDING>`                | The handle from your function. Returns stubs you call methods on.                 |
| [`ActorStub`](/docs/edge-compute/stateful-actors/api-reference/stub)            | from `idFromName` / `newUniqueId` | Per-name handle; an RPC to one instance.                                          |
| [Alarms](/docs/edge-compute/stateful-actors/alarms)                             | `ctx.storage` + `alarm()`         | Single per-actor alarm; at-least-once delivery.                                   |
| [Configuration](/docs/edge-compute/stateful-actors/api-reference/configuration) | `telnyx.toml` `[[actors]]`        | Declaring the actor binding.                                                      |
| [Errors](/docs/edge-compute/stateful-actors/api-reference/errors)               | `@telnyx/edge-runtime`            | `BlockConcurrencyTimeoutError`, `ActorOutputGateError`, timeout and codec errors. |

## Related Resources

* [Bindings](/docs/edge-compute/runtime/bindings) — how bindings resolve on `env`
* [KV](/docs/edge-compute/kv) — globally distributed key-value storage
* [Execution Model](/docs/edge-compute/runtime/execution-model) — function lifecycle and concurrency

***

### Base Class

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/base.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/base.md)

`StatefulActor` is the base class you extend. Subclass it, declare async methods, and they become RPC-callable from a stub. It holds `this.ctx` (see [Actor Context](/docs/edge-compute/stateful-actors/api-reference/context)) and `this.env` (your bindings).

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Account extends StatefulActor {
  async debit(amount: number): Promise<{ ok: boolean; balance: number }> {
    const balance = (await this.ctx.storage.get<number>("balance")) ?? 0;
    if (balance < amount) return { ok: false, balance };
    await this.ctx.storage.put("balance", balance - amount);
    return { ok: true, balance: balance - amount };
  }
}
```

## Construction

You don't construct actors yourself — the runtime does. The base constructor wires `this.ctx` and `this.env`, so a subclass that doesn't need init logic can omit its constructor entirely.

```ts theme={null}
class MyActor extends StatefulActor<Env> {
  // no constructor needed — this.ctx and this.env are wired by the base
}
```

If you do need one-shot init (preload state, set up an initial alarm), override the constructor and call `ctx.blockConcurrencyWhile` — see [Actor Context](/docs/edge-compute/stateful-actors/api-reference/context).

## Dispatch Rules

* **Public methods are RPC-callable** from a stub — declare them `async` (every stub call is a `Promise` on the caller's side regardless).
* Methods whose names start with `_` are internal helpers and are **not** RPC-exposed — the runtime rejects the call.
* The `_` prefix is the only opt-out. TypeScript's `private` keyword is erased at runtime and does **not** stop remote calls; name internal helpers with a leading `_`.
* `fetch(req)`, `alarm(info)`, `webSocket(ws, req)`, the constructor, and methods defined on `StatefulActor` itself (not your subclass) are special and not auto-RPC.
* **Methods have a wall-clock budget** (30s by default). A call that exceeds it fails with `ActorMethodTimeoutError` — see [Errors](/docs/edge-compute/stateful-actors/api-reference/errors).

## `alarm(alarmInfo)`

Optional alarm handler. Override to handle scheduled work. The base implementation is a no-op.

```ts theme={null}
async alarm(info: AlarmInfo): Promise<void> {
  // at-least-once delivery — be idempotent
  // info.retryCount is 0 on first delivery; info.isRetry is true on retries
}
```

See [Alarms](/docs/edge-compute/stateful-actors/alarms) for the delivery contract and retry policy.

## `fetch(req)`

Optional HTTP-style entry. The base default returns `404`. Override if you want callers to reach the actor over a raw `Request` instead of RPC methods:

```ts theme={null}
async fetch(req: Request): Promise<Response> {
  return new Response("hello from " + this.ctx.id);
}
```

Callable from a binding as `env.BINDING.idFromName(name).fetch(req)`.

## `webSocket(ws, req)`

Optional WebSocket entry. Unlike `alarm()` and `fetch()`, there is **no default implementation** — declaring the method is the opt-in. An actor that doesn't declare `webSocket()` has no socket behavior at all, and an upgrade routed at it is closed with code `1011` (reason `actor has no webSocket handler`).

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";
import type { WebSocket } from "ws";

export class Room extends StatefulActor {
  async webSocket(ws: WebSocket, req: Request): Promise<void> {
    const user = req.headers.get("x-user") ?? "anonymous";

    ws.on("message", async (data, isBinary) => {
      if (isBinary) return;
      // messages dispatch one at a time — this read-modify-write never races
      const seq = ((await this.ctx.storage.get<number>("seq")) ?? 0) + 1;
      await this.ctx.storage.put("seq", seq);
      ws.send(JSON.stringify({ echo: String(data), seq, user }));
    });
  }
}
```

* **Called once per accepted connection**, with the live socket and the handshake `Request`. `req` is the request your function's front door forwarded: the URL plus application headers, with transport and handshake headers stripped — see [WebSockets](/docs/edge-compute/stateful-actors/websockets) for the contract. Client-sent app headers pass through too, so trust only headers your front door explicitly set or overwrote — the front door must overwrite or strip anything auth-bearing (like `x-user` above) before forwarding.
* **`ws` is a Node `ws` socket** (`import type &#123; WebSocket &#125; from "ws"`): `ws.on("message", (data, isBinary) => ...)`, `ws.send(data)`, `ws.close(code, reason)`. There is no `WebSocketPair`/`accept()` shape.
* **Register your listeners before `webSocket()` returns.** Frames that arrive before `webSocket()` returns — including any the client sent immediately after the handshake — are buffered and replayed in order once it returns; nothing drops.
* **Socket events share the instance's single-threaded dispatch.** `message`, `close`, and `error` handlers run one at a time, serialized with RPC methods and alarms, each under the method wall-clock budget (30s by default). A message handler that throws or exceeds the budget closes the socket with code `1011`.

Connections reach the actor through your function's `fetch` front door: check the `Upgrade` header, authenticate, then forward with `env.BINDING.idFromName(name).fetch(...)`. See [WebSockets](/docs/edge-compute/stateful-actors/websockets) for the front door, connection lifetime, close codes, and delivery semantics.

## Related

* [WebSockets](/docs/edge-compute/stateful-actors/websockets) — the full connection contract behind `webSocket()`
* [Actor Context](/docs/edge-compute/stateful-actors/api-reference/context) — `this.ctx`: identity, storage, init
* [Actor Storage](/docs/edge-compute/stateful-actors/api-reference/storage) — `this.ctx.storage`
* [Configuration](/docs/edge-compute/stateful-actors/api-reference/configuration) — the `telnyx.toml` actor block

***

### Actor Context

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/context.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/context.md)

`this.ctx` (an `ActorContext`) holds the instance's identity, per-key storage, single alarm, one-shot init, and the instance's live WebSocket connections.

```ts theme={null}
interface ActorContext {
  readonly id: string;             // the name you chose with idFromName(name)
  readonly storage: ActorStorage;  // see Actor Storage
  blockConcurrencyWhile<T>(fn: () => Promise<T>): Promise<T>;
  setAlarm(when: number): Promise<void>;  // alias for ctx.storage.setAlarm(when)
  count(): number;                 // live WebSockets on this instance
  broadcast(data: string | ArrayBuffer | ArrayBufferView): number;  // returns sent count
}
```

## `ctx.id`

The customer-supplied `name` for this actor — opaque string. You chose it via `env.&lt;BINDING>.idFromName(name)`. Common patterns: caller E.164, CRM user id, email, or a composite key.

## `ctx.blockConcurrencyWhile(fn)`

One-shot init primitive. Use it inside a subclass constructor to gate all other calls until init finishes. **30s budget** — a callback that exceeds it fails init (`BlockConcurrencyTimeoutError`), and the activation is torn down and retried on the next call.

```ts theme={null}
class MyActor extends StatefulActor<Env> {
  constructor(ctx: ActorContext, env: Env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      // preload state, set up initial alarm, etc.
      // every other method call to this instance waits until this resolves
    });
  }
}
```

## `ctx.setAlarm(when)`

Top-level alias for `ctx.storage.setAlarm(when)`. `when` is **ms since epoch**. See [Alarms](/docs/edge-compute/stateful-actors/alarms).

## `ctx.count()`

The number of WebSocket connections open on **this instance** right now. Synchronous — no `await`. Scoped to the instance: sockets on other names of the same actor class are never counted, and a socket never carries over between names.

Callable from any handler — a socket message handler, an RPC method, or the alarm handler.

## `ctx.broadcast(data)`

Send one frame to every WebSocket open on **this instance**; returns the number of sockets the frame was sent to. A `string` is sent as a text frame; an `ArrayBuffer` or `ArrayBufferView` as a binary frame. Scoped to the instance, like `count()` — a broadcast never reaches another name's sockets. Like `ws.send()`, the write happens immediately; it is not held until the turn's storage writes commit.

Callable from any handler. Calling it from `alarm()` is the server-initiated push pattern — the actor sends with no inbound frame prompting it:

```ts theme={null}
async alarm(): Promise<void> {
  this.ctx.broadcast(JSON.stringify({ type: "tick", at: Date.now() }));
}
```

See [WebSockets](/docs/edge-compute/stateful-actors/websockets) for how sockets reach the actor in the first place.

## Related

* [Actor Storage](/docs/edge-compute/stateful-actors/api-reference/storage) — `ctx.storage`
* [Base Class](/docs/edge-compute/stateful-actors/api-reference/base) — where `this.ctx` comes from
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — the alarm contract
* [WebSockets](/docs/edge-compute/stateful-actors/websockets) — the connection contract behind `count()` and `broadcast()`

***

### Actor Storage

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/storage.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/storage.md)

`this.ctx.storage` (an `ActorStorage`) is the actor's per-key persistent storage. **A turn that returns successfully is persisted** — all writes made during the turn (put/delete/transaction/sql.exec/setAlarm) commit atomically at end-of-turn; a turn that throws commits nothing.

```ts theme={null}
interface ActorStorage {
  get<T = unknown>(key: string): Promise<T | undefined>;
  put<T = unknown>(key: string, value: T): Promise<void>;
  delete(key: string): Promise<boolean>;            // true iff the key existed
  list<T = unknown>(options?: ListOptions): Promise<Map<string, T>>;
  deleteAll(): Promise<void>;                        // atomic clear of all keys; does NOT clear a pending alarm
  transaction<T>(fn: (txn: StorageTransaction) => Promise<T>): Promise<T>;

  readonly sql: SqlStorage;                          // per-actor embedded SQLite (synchronous)
  transactionSync<T>(fn: () => T): T;                // atomic SQL transaction (synchronous)

  // Alarm API (mirrors ctx.setAlarm)
  // Available in local development; prod support is pending.
  setAlarm(when: number): Promise<void>;
  getAlarm(): Promise<number | null>;
  deleteAlarm(): Promise<void>;
}
```

Key behaviors:

* **Lazy per `get`** — state is not preloaded on activation. Call `get` when you need it.
* **Read-your-writes** — reads always reflect prior writes from the same actor.
* **Per-actor method serialization** — calls to one actor instance are dispatched one at a time, giving you effective ACID at the actor level.
* **Values round-trip through a codec.** JSON natives plus `Date`, `Map`, `Set`, typed arrays, `ArrayBuffer`, `BigInt`, and `RegExp` come back as what you stored. Unstorable values — functions, class instances, circular structures, promises, streams — are rejected with a `CodecError` at the write.
* **Keys starting with `__telnyx_` are reserved** for the runtime's own bookkeeping — writes to them are rejected.

## `list(options)`

Lexicographic key order; `reverse: true` flips it. Returns a `Map&lt;string, T>` to preserve ordering.

```ts theme={null}
interface ListOptions {
  prefix?: string;        // filter to keys starting with this string
  start?: string;         // inclusive
  startAfter?: string;    // exclusive
  end?: string;           // exclusive
  reverse?: boolean;
  limit?: number;         // default 128, max 1000 — a larger limit is rejected
}
```

## `transaction(fn)`

Atomic batch. `fn` receives a `StorageTransaction` with the same `get`/`put`/`delete`/`list` surface.

```ts theme={null}
await this.ctx.storage.transaction(async (txn) => {
  const v = (await txn.get<number>("count")) ?? 0;
  await txn.put("count", v + 1);
  await txn.put("updated_at", Date.now());
});
```

If `fn` throws, the buffered writes are discarded — **and the enclosing method call
fails with `ActorOutputGateError`**, even if your code catches the rejection. Only the
transaction's own writes are undone: writes you make after catching the rejection still
commit, but the caller sees the error instead of your return value. A thrown transaction
is not a control-flow tool for "try the write, continue on failure".

## `sql`

`ctx.storage.sql` is a private embedded SQLite database for this actor — a separate,
synchronous store beside the key/value surface above. See
[SQL storage](/docs/edge-compute/stateful-actors/guides/storage/sql) for the `exec` /
cursor contract, types, transactions, limits, and durability.

## `transactionSync(fn)`

Atomic SQL transaction: runs `fn` synchronously, commits its `sql.exec` writes when it
returns (passing the return value through), rolls all of them back if it throws. `fn` must
not be `async` — an async callback throws `SqlAsyncTransactionError` and commits nothing.
This is the only transaction API for SQL: `exec()` rejects raw `BEGIN` / `COMMIT` /
`ROLLBACK` / `SAVEPOINT` with `SqlTransactionControlError`. Unlike a thrown
[`transaction(fn)`](#transactionfn), a thrown `transactionSync` does **not** poison the
turn: catch it and the method call completes normally.

## Related

* [Key-Value storage](/docs/edge-compute/stateful-actors/guides/storage/key-value) — the `get`/`put`/`list` guide
* [SQL storage](/docs/edge-compute/stateful-actors/guides/storage/sql) — the `ctx.storage.sql` guide
* [Actor Context](/docs/edge-compute/stateful-actors/api-reference/context) — `ctx.storage` lives here
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — the `setAlarm` / `getAlarm` / `deleteAlarm` contract

***

### Actor Namespace

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/namespace.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/namespace.md)

`env.&lt;BINDING>` is an `ActorNamespace` — the handle on `env` that your function calls into. It's declared in `telnyx.toml`:

```toml theme={null}
[[actors]]
binding = "ACCOUNT"    # env.ACCOUNT
type    = "Account"    # the class to instantiate per name
```

```ts theme={null}
interface ActorNamespace {
  idFromName(name: string, options?: IdFromNameOptions): ActorStub;
  newUniqueId(options?: IdFromNameOptions): ActorStub;
}

interface IdFromNameOptions {
  locationHint?: LocationHint;     // "us-east" | "us-west" | "eu" | "apac" | (string & {})
}
```

## `idFromName(name, options?)`

Deterministic — the same `name` always lands on the same actor instance. This is the most common call: pick a stable identity (caller E.164, CRM id, room id) and let the platform route to the right state.

`locationHint` is a placement **hint**, relevant on first touch only — an actor materializes once, so a hint on a later call can't move it. The platform may ignore the hint entirely (today it does — the option is accepted and reserved for regional placement). Abstract region codes (the taxonomy can evolve without breaking your code).

## `newUniqueId(options?)`

Random — a fresh, unguessable instance. Use when you want the runtime to mint the identity for you (e.g. a one-shot job actor). The id is generated client-side; keep it (via `stub.id`) if you'll need to reach the instance again.

`newUniqueId` currently fails at call time on the platform (a known runtime issue).
Until it's fixed, mint an identity yourself and pass it to `idFromName`.

Both return an [Actor Stub](/docs/edge-compute/stateful-actors/api-reference/stub). Method dispatch on the stub is provided by the runtime via a Proxy; in TypeScript, run `telnyx-edge types` to type `env.&lt;BINDING>` against your class's public method shape, or hand-roll the narrowing — the [Actor Stub](/docs/edge-compute/stateful-actors/api-reference/stub) page shows both.

## Related

* [Actor Stub](/docs/edge-compute/stateful-actors/api-reference/stub) — what `idFromName` / `newUniqueId` return
* [Configuration](/docs/edge-compute/stateful-actors/api-reference/configuration) — declaring the binding
* [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) — naming and routing

***

### Actor Stub

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/stub.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/stub.md)

An `ActorStub` is the client handle returned by [`idFromName` / `newUniqueId`](/docs/edge-compute/stateful-actors/api-reference/namespace). Calling a method on it is an RPC to that one actor instance.

```ts theme={null}
interface ActorStub {
  readonly id: string;                       // the name passed to idFromName
  fetch(req: Request): Promise<Response>;    // HTTP-style entry (see Base Class fetch)
  // + your subclass's public methods — typed via `telnyx-edge types` or by hand, below
}
```

`telnyx-edge types` generates the narrowing for every `[[actors]]` binding in
`telnyx.toml` — a `telnyx-env.d.ts` that types `env.&lt;BINDING>` against your class's
public method shape. To hand-roll it instead (also the path for a
[shared-actor reference](/docs/edge-compute/stateful-actors/shared-actors), which
ships no local class for codegen to read):

```ts theme={null}
import { type ActorNamespace, type ActorStub, type IdFromNameOptions } from "@telnyx/edge-runtime";

type AccountStub = ActorStub & Pick<Account, "deposit" | "debit" | "balance">;

interface AccountNamespace extends ActorNamespace {
  idFromName(name: string, options?: IdFromNameOptions): AccountStub;
}

interface Env { ACCOUNT: AccountNamespace }
```

## Related

* [Actor Namespace](/docs/edge-compute/stateful-actors/api-reference/namespace) — produces stubs
* [Base Class](/docs/edge-compute/stateful-actors/api-reference/base) — the `fetch` handler a stub can call

***

### Actor Alarms

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/alarms.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/alarms.md)

Every Stateful Actor has a **single per-instance alarm** — a one-shot timer you can set, replace, read, or clear from inside any method. When the alarm fires, the runtime calls the actor's `alarm(alarmInfo)` handler. Alarms are the way to do deferred work without an external scheduler.

| What                     | How                                                                |
| ------------------------ | ------------------------------------------------------------------ |
| Set or replace the alarm | `await this.ctx.storage.setAlarm(when)` — `when` is ms since epoch |
| Read the scheduled time  | `await this.ctx.storage.getAlarm()` → `number \| null`             |
| Clear the alarm          | `await this.ctx.storage.deleteAlarm()`                             |
| Top-level alias          | `await this.ctx.setAlarm(when)` (mirrors `storage.setAlarm`)       |
| Handle the fire          | override `async alarm(info: AlarmInfo)` on your subclass           |

There is one alarm per actor instance, not per method or per key. Setting it again replaces the previous time.

## Set and Handle

```ts theme={null}
import { StatefulActor, type AlarmInfo } from "@telnyx/edge-runtime";

export class Session extends StatefulActor {
  // Called when a session is touched — push the timeout out by 5 minutes.
  async touch() {
    await this.ctx.storage.put("last_seen", Date.now());
    await this.ctx.storage.setAlarm(Date.now() + 5 * 60_000);
  }

  // Fires once when the alarm goes off.
  async alarm(info: AlarmInfo): Promise<void> {
    const lastSeen = (await this.ctx.storage.get<number>("last_seen")) ?? 0;
    if (Date.now() - lastSeen > 5 * 60_000) {
      // session actually idle — clean up
      await this.ctx.storage.deleteAll();
    }
  }
}
```

The handler is just another method call on the actor — it inherits the same single-threaded dispatch guarantee as any other method. You don't need a lock around the state the alarm touches.

## Delivery Contract

`AlarmInfo`:

```ts theme={null}
interface AlarmInfo {
  retryCount: number;   // 0 on first delivery; increments on each retry
  isRetry: boolean;     // true when retryCount > 0
}
```

* **At-least-once delivery.** Plan for the handler to run more than once for the same scheduled time. Make writes idempotent — re-check state in `storage` before applying effects (as the `Session` example above does), or record a done-marker keyed by the scheduled time.
* **A failed run is redelivered 3 times, about a second apart.** If the handler throws (or exceeds its time budget), the platform re-fires it with `retryCount` incremented — 4 attempts total, then the alarm is **deleted** (`getAlarm()` returns `null`). There is no signal when that happens.
* **A failing handler loses its alarm — don't use `throw` as your retry mechanism.** Three retries a second apart won't outlast a real outage. If the deferred work must happen, make the handler tolerate its own errors: catch, record, and re-arm with `setAlarm` at a backoff you choose.
* **Use `info.isRetry` to branch.** On retry, log it, treat the work as best-effort, or skip already-completed steps.

```ts theme={null}
async alarm(info: AlarmInfo): Promise<void> {
  if (info.isRetry) {
    // redelivery after a failed run — be extra careful about double-applies
  }
  // re-check state in storage before applying effects, then do the work
}
```

## Patterns

### TTL / expiry

Set the alarm when you create the row; on fire, check the row is still expired and delete it. If the row was touched since, reset the alarm instead.

### Retry backoff

If your method kicks off a flaky external call, set an alarm to retry later; on fire, attempt again and either succeed or set a new alarm with a longer backoff.

### Session timeout

See `Session.touch` above — every interaction pushes the alarm out by the idle window; the alarm only fires if no one touches the session in time.

### Coalesced drain

Because there's only one alarm per instance, you can use it as a "flush trigger": set it on the first write, and when it fires, drain the queue and clear it. Subsequent writes during the window just append to the queue.

## Limits

* **One alarm per actor instance.** If you need multiple timers, keep a queue in `storage` and use the single alarm to drive a scheduler loop.
* **`when` is ms since epoch.** Don't pass seconds. Firing precision is about one second — don't schedule sub-second work with it.
* **No recurring flag.** If you want periodic work, set the next alarm at the end of your `alarm` handler.
* **The handler has a time budget.** `alarm()` runs under its own wall-clock cap — larger than the 30-second method budget, on the order of minutes, but still bounded. A run that exceeds it counts as a failure and is redelivered.
* **`deleteAll()` does not clear the alarm.** Keys and the alarm are separate; a pending alarm still fires after `deleteAll()`, and a handler that re-arms itself will keep running against empty state. To decommission an actor: `deleteAlarm()` first, then `deleteAll()`.

## Next Steps

* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `ActorStorage`, `ActorContext`, `AlarmInfo`
* [Quick Start](/docs/edge-compute/stateful-actors/quick-start) — Scaffold, deploy, and curl an Account actor
* [Shared Actors](/docs/edge-compute/stateful-actors/shared-actors) — Reach one actor from many functions

***

### Configuration

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/configuration.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/configuration.md)

A Stateful Actor project uses the umbrella `telnyx.toml` manifest (the same file your `[[secrets]]` and `[telnyx]` bindings live in). The actor declaration is a `[[actors]]` array:

```toml theme={null}
name = "account"                # function name
main = "src/index.ts"          # entry — exports the fetch handler (and the class, if this func owns the type)
compatibility_date = "2026-05-01"

[[actors]]
binding = "ACCOUNT"           # the property on env — your handle
type    = "Account"           # the class to instantiate per name
```

| Field                | Meaning                                                                                                                                                                                                          |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`               | Function name (used by the platform to register the func)                                                                                                                                                        |
| `main`               | Entry module — exports the `default &#123; fetch &#125;` handler, plus the actor class when this function owns the type (a [reference binding](/docs/edge-compute/stateful-actors/shared-actors) ships no class) |
| `compatibility_date` | Runtime compatibility pin                                                                                                                                                                                        |
| `[[actors]] binding` | The name you'll use as `env.&lt;binding>` from your function                                                                                                                                                     |
| `[[actors]] type`    | The class name to instantiate per actor name                                                                                                                                                                     |

Constraints, enforced at ship time: `binding` and `type` must be identifiers and must
not contain `__`; `type` is capped at 32 characters; binding names must be unique
across all bindings in the file, and `SECRETS` is reserved as a binding name when the
file has a `[[secrets]]` block. Multiple `[[actors]]` blocks are allowed — including
two bindings pointing at the same `type`.

## Related

* [Actor Namespace](/docs/edge-compute/stateful-actors/api-reference/namespace) — the `env.&lt;binding>` this declares
* [Project Structure](/docs/edge-compute/stateful-actors/guides/project-structure) — the two-export project shape

***

### Errors

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/errors.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/api-reference/errors.md)

| Error                          | Meaning                                                                                                                                                                                                                                     |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BlockConcurrencyTimeoutError` | `ctx.blockConcurrencyWhile`'s callback exceeded the 30s budget                                                                                                                                                                              |
| `ActorOutputGateError`         | A storage write initiated during a method rejected after the method returned; surfaces in place of the successful return value, with the underlying failure appended to the error message. Also raised when a `transaction` callback throws |
| `ActorMethodTimeoutError`      | An RPC method exceeded its wall-clock budget (30s by default). The invocation fails; the instance is not torn down                                                                                                                          |
| `ActorFetchTimeoutError`       | The actor's `fetch(req)` handler exceeded its wall-clock budget (30s)                                                                                                                                                                       |
| `CodecError`                   | A method argument, return value, or storage value could not be serialized (e.g. a function, a circular structure)                                                                                                                           |

On the **caller** side, a failed stub call rejects with an `Error` whose `name` identifies
the cause — for example `ActorMethodError` (your method threw; message preserved),
`ActorPrivateMethodError` (`_`-prefixed method), or `ActorUnknownMethodError`. Branch on
`err.name` if you need to distinguish them.

## Related

* [Actor Context](/docs/edge-compute/stateful-actors/api-reference/context) — `blockConcurrencyWhile`
* [Execution Model](/docs/edge-compute/stateful-actors/concepts/execution-model) — the output-gate guarantee behind `ActorOutputGateError`

***

## Guides

### Project Structure

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/project-structure.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/project-structure.md)

A Stateful Actor project ships from **one module that exports two things**, wired
together by a binding declared in `telnyx.toml`.

## Two Exports

One module exports the **actor class** and a **`fetch` handler** — an ordinary
Edge Compute function that calls the actor through a binding on `env`. The deploy
pipeline routes each export to the right runtime.

```ts theme={null}
import { type ActorNamespace, type ActorStub } from "@telnyx/edge-runtime";
import { Account } from "./account";
export { Account };

type AccountStub = ActorStub & Pick<Account, "debit">;
interface AccountNamespace extends ActorNamespace {
  idFromName(name: string): AccountStub;
}
interface Env { ACCOUNT: AccountNamespace }

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const acct = new URL(req.url).pathname.slice(1);      // /acct_123
    const { amount } = (await req.json()) as { amount: number };
    const result = await env.ACCOUNT.idFromName(acct).debit(amount);
    return Response.json(result);
  },
};
```

## The Binding

`env.ACCOUNT` is declared in `telnyx.toml` as an `[[actors]]` entry — `binding` is the
property on `env`, `type` is the class to instantiate per name:

```toml theme={null}
name = "account-svc"
main = "src/index.ts"

[[actors]]
binding = "ACCOUNT"   # the property on env — your handle
type    = "Account"   # the class to instantiate per name
```

See the [Runtime API](/docs/edge-compute/stateful-actors/api-reference) for the full
`telnyx.toml` actor block.

The [Quick Start](/docs/edge-compute/stateful-actors/quick-start) builds and deploys
exactly this shape — the `Account` actor plus its `fetch` handler — end to end.

## Next Steps

* [Shared actors](/docs/edge-compute/stateful-actors/shared-actors) — reach one actor type from multiple functions
* [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) — naming and routing instances
* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — the binding surface

***

### Local Development

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/local-development.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/local-development.md)

A plain Edge Compute function is an ordinary program you run and `curl` directly — the [Functions local-dev guide](/docs/edge-compute/development) covers that. A Stateful Actor project is different: the `fetch` handler reaches its actors through an `env.&lt;BINDING>` call, and that binding resolves only against a running actor runtime. There is no in-process emulation to fall back on.

`telnyx-edge dev` supplies the missing runtime. It generates the actor stack your project needs, boots it in Docker, serves your `fetch` handler on a local port, and hot-reloads on save — so `env.&lt;BINDING>` calls hit real actors before you ship.

`telnyx-edge dev` is a development command; it does not deploy anything. When it works locally, ship with [`telnyx-edge ship`](/docs/edge-compute/deploy).

## Prerequisites

* **Docker**, running. The stack is a set of containers booted with `docker compose`.
* **A `telnyx.toml` umbrella project** — the two-export shape from [Project structure](/docs/edge-compute/stateful-actors/guides/project-structure): one module that exports an actor class and a `fetch` handler, wired by an `[[actors]]` binding. Run `telnyx-edge dev` from the directory that holds the manifest, or point at it with `-f/--from-dir`. Without a `telnyx.toml`, the command errors:

  ```
  ❌ No project manifest found
  💡 Run 'telnyx-edge dev' from a directory containing a telnyx.toml manifest.
  ```

  The [Quick Start](/docs/edge-compute/stateful-actors/quick-start) scaffolds exactly this shape with `telnyx-edge new-func --actor`.

## The Loop

From an umbrella project directory:

```bash theme={null}
telnyx-edge dev
```

This bundles the project, generates the stack under `.telnyx/dev`, and boots it. Your `fetch` handler is served on `http://localhost:8787` (change it with `--port`). Exercise it the same way the [Quick Start](/docs/edge-compute/stateful-actors/quick-start) exercises the deployed URL — but against localhost, and with actors resolving locally:

```bash theme={null}
B=http://localhost:8787

curl -sS -X POST $B/accounts/alice/deposit -H 'content-type: application/json' -d '{"amount":100}'
# → {"account":"alice","balance":100}
curl -sS -X POST $B/accounts/alice/debit   -H 'content-type: application/json' -d '{"amount":30}'
# → {"account":"alice","ok":true,"balance":70}
curl -sS $B/accounts/alice/balance
# → {"account":"alice","balance":70}
```

Each `env.ACCOUNT.idFromName(...)` call routes to a real actor instance in the local stack — the same code path that runs deployed. Edit your actor class or `fetch` handler and save; `telnyx-edge dev` watches the source and hot-reloads the affected runtime. Actor state lives in the stack's Postgres store, so it **survives a reload** — `alice` is still `70` after you edit and save.

Press **Ctrl-C** to stop watching. The stack keeps running; the command only detaches the file watcher.

## What the Stack Contains

`telnyx-edge dev` writes the stack under `.telnyx/dev` and runs it with `docker compose`. It is a local stand-in for the deployed platform, containing:

* the **function runtime** — serves your `fetch` handler on the published port;
* the **actor runtime** — hosts your actor instances;
* a **Dapr sidecar** for each runtime, plus **Dapr placement** — the routing layer that sends each actor name to its owner;
* a **Postgres actor state store** — where `ctx.storage` persists, so state survives reloads.

`env.&lt;BINDING>` calls from the function runtime reach the actors in the actor runtime through this layer. This is what a plain `func.toml` function lacks: it has no local binding emulation, so binding-backed code paths only run once deployed.

## Flags

| Flag                      | Default                       | Purpose                                                                    |
| ------------------------- | ----------------------------- | -------------------------------------------------------------------------- |
| `--port int`              | `8787`                        | Host port to publish the function runtime on.                              |
| `--no-watch`              | —                             | Boot the stack and return, without watching for changes.                   |
| `--generate-only`         | —                             | Write the stack files under `.telnyx/dev` but do not run `docker compose`. |
| `-f, --from-dir string`   | —                             | Path to the project directory (relative, absolute, or `~/path`).           |
| `--function-image string` | `telnyx/function-runtime:dev` | Container image for the function runtime.                                  |
| `--actor-image string`    | `telnyx/actor-runtime:dev`    | Container image for the actor runtime.                                     |
| `-v, --verbose`           | —                             | Verbose logging.                                                           |

Use `--generate-only` to inspect or customize the generated compose stack before booting it, and `--no-watch` when you want the stack up for an out-of-band test run (for example, from a CI script) rather than an interactive edit loop.

## Notes

* **Actor state survives reloads.** Because `ctx.storage` persists to the stack's Postgres store, a hot reload does not reset your instances — balances, counters, and any other state carry across saves. Tear the stack down with `docker compose` (or remove `.telnyx/dev`) when you want a clean slate.
* **Ctrl-C leaves the stack running.** It stops the watcher, not the containers. Stop the stack explicitly with `docker compose` when you're done.
* **A `telnyx.toml` is required.** `telnyx-edge dev` runs umbrella projects. A single-function `func.toml` project has no actors to host — develop those with the [Functions local-dev workflow](/docs/edge-compute/development) instead.

## Next Steps

* [Quick Start](/docs/edge-compute/stateful-actors/quick-start) — scaffold, write, and deploy an `Account` actor end to end
* [Project structure](/docs/edge-compute/stateful-actors/guides/project-structure) — the two-export shape `telnyx-edge dev` runs
* [Deploy](/docs/edge-compute/deploy) — ship, revisions, and rollback

***

### Shared Actors

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/shared-actors.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/shared-actors.md)

A **shared actor** is a single Stateful Actor type reachable from two or more Edge Compute functions on the same account. The function that ships the actor class **owns** the actor; other functions on the same account declare the same `type` under their own `binding` name and ship **no** class — their binding routes to the owner's actor and its state.

## Why Share an Actor

* **Split read and write paths** into separate functions with their own auth, rate limits, and deploy cadence — without duplicating state.
* **Let a second function call into a domain the first one owns** (e.g., a reporting function reads an account actor that a payments function owns).
* **Keep one durable actor behind many entrypoints.** Both functions talk to the same per-name state; writes through one binding are visible to reads through the other.

Both functions must be on the **same account** — shared actors are bounded by account.

## The Pattern

The owner function is a normal Stateful Actor project — it ships the class. The reference function declares the same `type` under a different `binding` and ships **no** actor class — its binding routes to the owner's actor and state; no second instance is created.

There's no `new-func` flag for "reference-only," so the workflow is: scaffold with `--actor` (to mint a `func_id` and write `telnyx.toml`), then drop the actor class and rename the binding.

## Walkthrough: a Read-Only Account Reference

Prereq: an owner function for the type already shipped on your account (the `account` from the [Quick Start](/docs/edge-compute/stateful-actors/quick-start), type `Account`).

### 1. Mint a `func_id` and scaffold, then drop the class

```bash theme={null}
telnyx-edge new-func --actor --name=account-readonly   # registers the func, writes func_id
cd account-readonly
rm -f src/counter.ts                                    # the ref ships no actor class
```

### 2. Point `telnyx.toml` at the owner's type under a new binding

Edit the `[[actors]]` block — keep `type` equal to the owner's type, change `binding`:

```toml theme={null}
[[actors]]
binding = "READONLY"    # this func's own handle
type    = "Account"     # SAME type the owner declares → shared actor
```

Leave `name`, `main`, `compatibility_date`, and the `[edge_compute]` `func_id` as scaffolded.

### 3. Replace `src/index.ts` with a class-free handler that calls `env.READONLY`

```ts theme={null}
import {
  type ActorNamespace,
  type ActorStub,
  type IdFromNameOptions,
} from "@telnyx/edge-runtime";

// Hand-rolled stub narrowing (mirrors the owner's read surface; no local class).
type AccountStub = ActorStub & {
  balance(): Promise<{ balance: number }>;
};
interface AccountNamespace extends ActorNamespace {
  idFromName(name: string, options?: IdFromNameOptions): AccountStub;
}
interface Env { READONLY: AccountNamespace }   // <- the binding from telnyx.toml

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname === "/health/liveness") return new Response("ok");
    if (url.pathname === "/health/readiness") return new Response("ok");

    // Read-only view: balance for an account
    const m = url.pathname.match(/^\/accounts\/([^/]+)\/balance$/);
    if (m && req.method === "GET") {
      const result = await env.READONLY.idFromName(m[1]!).balance();
      return Response.json({ account: m[1], ...result, via: "READONLY->Account(shared)" });
    }
    return new Response("not found", { status: 404 });
  },
};
```

**Key point:** no `export class Account` in this file. The bundler roots at `main`, so with nothing importing the class, the reference bundle ships class-free (a few KB) — that's what makes it reference-only.

### 4. Install deps and ship (same as the owner)

```bash theme={null}
npm install
telnyx-edge ship
```

### 5. Prove they share one actor

```bash theme={null}
REF=https://account-readonly-<id>.telnyxcompute.com
OWN=https://account-<id>.telnyxcompute.com

# Write via the owner (ACCOUNT binding) — carol is a fresh account name
curl -sS -X POST $OWN/accounts/carol/deposit \
  -H 'content-type: application/json' -d '{"amount":100}'

# Read via the reference (READONLY binding) → same balance
curl -sS $REF/accounts/carol/balance
# → {"account":"carol","balance":100,"via":"READONLY->Account(shared)"}
```

Write through `ACCOUNT`, read through `READONLY`, same `balance` → one durable actor (Account), two functions. Both must be on the same account; the reference's `type` must exactly match the owner's.

## Rules of Thumb

* **Same account, same `type`.** Reference bindings whose `type` doesn't match an owner already on the account won't resolve.
* **Only one function owns the class.** The owner ships the actor class; every other function declares the same `type` and ships no class.
* **Renaming the binding is fine.** Each function picks its own `binding` name — that's the property on `env` for that function. The `type` is what glues them together.
* **Reference bundles are small.** With no class import, the bundler emits a class-free bundle. Keep it that way: don't import the actor class from your reference function.

## Next Steps

* [Quick Start](/docs/edge-compute/stateful-actors/quick-start) — ship the owner function this reference calls into
* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `ActorNamespace`, `ActorStub`, and the binding surface
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — schedule deferred work from inside an actor method

***

### When to Use Stateful Actors

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/when-to-use.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/when-to-use.md)

Use a stateful actor when an **entity has state that must be mutated coherently across
many requests** — a cart, a chat room, a call leg's media state, a per-user rate
limiter, a job coordinator. One instance per name gives you serialized, durable, private
state for that entity without locks or a shared database.

## When Not to Reach for One

* **Stateless work** (routing, transforms, independent fan-out): use a plain function.
  An actor only adds a routing hop and a serialization point you don't need.
* **A global counter or singleton:** one name is one single-threaded instance is one
  throughput ceiling. Route everything through `idFromName("global")` and you've
  rebuilt the single-lock bottleneck. Shard across many names so load spreads across
  instances.
* **Large blobs or bulk scans:** the keyspace holds one entity's working set, not a
  warehouse. Use object storage or a database.
* **Cross-entity transactions or ad-hoc queries:** each instance is its own
  consistency domain. Within one actor, writes are serialized and `transaction()`
  gives you atomic multi-key updates — but there is no transaction across two actors,
  and there's no `JOIN` across them. If you need a
  transaction spanning many entities, or to query across them, that's what a database
  is for — keep it.

A single instance processes calls serially, so its ceiling is roughly
`1 / (method wall-time)` calls per second — the same ceiling one thread per account
gives you. Sharding by a well-chosen name is how you scale.

## Next Steps

* [How it works](/docs/edge-compute/stateful-actors/concepts/how-it-works) — the execution model and its limits
* [Addressing](/docs/edge-compute/stateful-actors/concepts/addressing) — choosing names that spread load
* [Shared actors](/docs/edge-compute/stateful-actors/shared-actors) — one actor, many functions

***

### Key-Value

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/key-value.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/key-value.md)

Every Stateful Actor has private, **durable** key/value storage at `this.ctx.storage`. It is
the default place to keep an entity's state: a cart's items, a call leg's status, a
per-user counter. Reach for it first, and drop to [SQL](/docs/edge-compute/stateful-actors/guides/storage/sql)
only when a lookup by key isn't enough and you'd otherwise scan.

The store is **private, strongly consistent, and transactional**: only this actor instance
reads and writes its keys, its methods run one at a time
([serialized turns](/docs/edge-compute/stateful-actors/concepts/execution-model)), and a
write that returns successfully is durable — it survives the actor being evicted, relocated,
or its pod replaced. Reads always reflect your prior writes.

This is a guide. For the exact method signatures, list options, and codec rules, see the
[Actor Storage reference](/docs/edge-compute/stateful-actors/api-reference/storage).

## Reading and Writing

`get` and `put` are the whole surface for simple state. Both are async — they may cross the
network — so `await` them:

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Cart extends StatefulActor {
  async add(item: string) {
    const items = (await this.ctx.storage.get<string[]>("items")) ?? [];
    items.push(item);
    await this.ctx.storage.put("items", items);
    return items.length;
  }

  async clear() {
    await this.ctx.storage.delete("items");   // returns true iff the key existed
  }
}
```

State is **lazy per `get`** — nothing is preloaded when the actor activates; you read a key
when you need it. Values round-trip through a codec, so JSON natives plus `Date`, `Map`,
`Set`, typed arrays, `ArrayBuffer`, `BigInt`, and `RegExp` come back as what you stored.
Functions, class instances, promises, and circular structures are rejected at the write.

Keys are strings. Those starting with `__telnyx_` are reserved for the runtime — writes to
them are rejected.

## Listing Keys

`list` returns a `Map` in lexicographic key order, which makes prefixes a natural grouping:

```ts theme={null}
// Every message in a room, in order:
const messages = await this.ctx.storage.list<Message>({ prefix: "msg:" });

// The 20 most recent, newest first:
const recent = await this.ctx.storage.list<Message>({
  prefix: "msg:",
  reverse: true,
  limit: 20,
});
```

`limit` defaults to 128 and maxes at 1000. Use `start` / `startAfter` / `end` to page
through a large keyspace. See the [reference](/docs/edge-compute/stateful-actors/api-reference/storage)
for the full `ListOptions`.

## Atomic Updates

`transaction` batches multiple keys into one atomic unit — all of the writes land, or none
do:

```ts theme={null}
await this.ctx.storage.transaction(async (txn) => {
  const balance = (await txn.get<number>("balance")) ?? 0;
  await txn.put("balance", balance - amount);
  await txn.put("updated_at", Date.now());
});
```

If the callback throws, its buffered writes are discarded. Note that a thrown transaction
also fails the enclosing method call — it is not a "try the write, continue on failure"
tool. See the [reference](/docs/edge-compute/stateful-actors/api-reference/storage#transactionfn)
for the exact semantics.

## Clearing an Actor

`deleteAll()` atomically removes every key for this actor. It does **not** clear a pending
[alarm](/docs/edge-compute/stateful-actors/alarms) — delete that separately if you want the
actor fully reset.

## Key/Value or SQL?

Both live under `this.ctx.storage`, are durable and strongly consistent, and can be used
together in the same actor.

* **Key/value** (this page): simple keyed state, prefix listing, atomic multi-key updates.
  The default.
* **[SQL](/docs/edge-compute/stateful-actors/guides/storage/sql)**: relational shape,
  aggregates, `GROUP BY`, secondary indexes, and joins *within one actor* — when a keyed
  lookup isn't enough.

## Related

* [SQL storage](/docs/edge-compute/stateful-actors/guides/storage/sql) — the `ctx.storage.sql` guide
* [Actor Storage reference](/docs/edge-compute/stateful-actors/api-reference/storage) — full method signatures and codec rules
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — scheduled self-wakeups

***

### SQL

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/sql.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/guides/storage/sql.md)

Every Stateful Actor has a private, **durable** embedded **SQLite** database reachable at
`this.ctx.storage.sql`. It sits beside the [key/value store](/docs/edge-compute/stateful-actors/guides/storage/key-value)
under the same `this.ctx.storage`, but it is a separate engine: one SQLite database per
actor instance, queried with real SQL.

Reach for it when a single entity's state is relational or needs queries the key/value
store can't answer without a scan — aggregates, `GROUP BY`, secondary indexes, joins
*within* the one actor. It is not a shared database: each instance sees only its own data,
and there are no cross-actor queries or joins. When more than one function — or an operator at
a terminal — has to query the same rows, use a
[SQL Database](/docs/edge-compute/sqldb) instead: a standalone SQLite database reachable from
any function that binds it, from the CLI, and from the REST API.

The database is **private, strongly consistent, and transactional**: one actor instance is
the only reader and writer of its database, its methods run one at a time
([serialized turns](/docs/edge-compute/stateful-actors/concepts/execution-model)), and a
write that returns successfully is durable at turn granularity — it survives the actor being evicted, relocated,
or its pod replaced. Reads always reflect your prior writes.

Stateful Actors are in Beta. `ctx.storage.sql` requires `@telnyx/edge-runtime` **0.6.0 or
later**; the `transactionSync()` type ships in **0.8.0**, so use the latest SDK.

## The Surface

```ts theme={null}
interface SqlStorage {
  exec<T = Record<string, SqlValue>>(query: string, ...bindings: SqlValue[]): SqlCursor<T>;
}

interface SqlCursor<T> extends Iterable<T> {
  toArray(): T[];
}

type SqlValue = null | number | string | ArrayBuffer;
```

`exec` is **synchronous** — it does not return a `Promise`. This is deliberate and unlike
every other member of `ctx.storage`: the database is a local file in the actor's own
process, so there is no network hop to await.

```ts theme={null}
import { StatefulActor } from "@telnyx/edge-runtime";

export class Inventory extends StatefulActor {
  async record(sku: string, cents: number) {
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS orders(sku TEXT, cents INTEGER, ts INTEGER)`,
    );
    this.ctx.storage.sql.exec(
      `INSERT INTO orders(sku, cents, ts) VALUES (?, ?, ?)`,
      sku,
      cents,
      Date.now(),
    );
    // An aggregate the key/value store can't answer without scanning every key:
    return this.ctx.storage.sql
      .exec(
        `SELECT sku, SUM(cents) AS total FROM orders
         GROUP BY sku ORDER BY total DESC LIMIT 5`,
      )
      .toArray();
  }
}
```

There is no schema step in the manifest — any actor that calls `ctx.storage.sql` gets a
database, created on first use. Create your tables with `CREATE TABLE IF NOT EXISTS` (a
cheap no-op once they exist) at the top of the methods that need them.

## Binding Parameters

Use positional `?` placeholders and pass the values as trailing arguments. Always bind
user input — never build SQL by string concatenation. (Binding here is SQL's own term for
attaching values to placeholders — no relation to the `telnyx.toml` bindings your function
receives on `env`.)

```ts theme={null}
const rows = this.ctx.storage.sql
  .exec(`SELECT * FROM orders WHERE sku = ? AND cents > ?`, sku, 1000)
  .toArray();
```

A bound value must be one of four types: `null`, `number`, `string`, or `ArrayBuffer`
(for binary). Result columns come back as those same four types:

| SQLite type | JavaScript value you get back |
| ----------- | ----------------------------- |
| `INTEGER`   | `number`                      |
| `REAL`      | `number`                      |
| `TEXT`      | `string`                      |
| `BLOB`      | `ArrayBuffer`                 |
| `NULL`      | `null`                        |

## Reading Results: the Cursor

`exec` returns a `SqlCursor` — a **single-pass** iterator over the result rows. Drain it
once, either by iterating or with `toArray()`:

```ts theme={null}
// Drain to an array (the common case):
const all = this.ctx.storage.sql.exec(`SELECT n FROM nums ORDER BY n`).toArray();

// Or stream row by row, without materializing the whole set:
for (const row of this.ctx.storage.sql.exec(`SELECT n FROM nums ORDER BY n`)) {
  handle(row.n);
}
```

Single-pass means the rows are consumed as you read them. After you iterate a cursor to
the end, `toArray()` on that same cursor returns `[]`; calling `toArray()` a second time
also returns `[]`. If you iterate partway and then call `toArray()`, you get the *rest* of
the rows. Run `exec` again for a fresh pass.

`exec` is generic in the row type — `exec&lt;Order>(...)` types the returned rows — but the
type is a compile-time assertion only, not validated at runtime, so make sure your query
returns those columns.

## Multiple Statements in One `exec`

A query string may contain several `;`-separated statements — useful for pasting a schema
or a small migration. Every statement executes, in order; the bound parameters apply to
the **last** statement, and the returned cursor is over the **last** statement's rows:

```ts theme={null}
const rows = this.ctx.storage.sql
  .exec(
    `CREATE TABLE IF NOT EXISTS t(x INTEGER);
     INSERT INTO t VALUES (1);
     SELECT x FROM t WHERE x >= ?;`,
    1,
  )
  .toArray(); // the final SELECT's rows; the CREATE and INSERT ran too
```

Statements run independently — if one fails, `exec` throws, but the statements before it
have already been applied. Wrap the batch in a [`transactionSync`](#transactions) callback
when it must be all-or-nothing.

Multi-statement splitting is a best-effort TypeScript tokenizer, not exact SQLite
parsing. It handles all realistic SQL, but unquoted keyword-identifiers (a column named
`end`, `begin`, or `case` in a deep position) can confuse the splitter. **Quote
reserved-word identifiers** (`"end"`) to avoid edge cases. Exact parity is tracked in
[COMPUTE-470](https://linear.app/telnyx/issue/COMPUTE-470).

## Transactions

Wrap related writes so they commit together — or roll back together — with
`transactionSync`. It runs your callback synchronously, commits when it returns, and rolls
back every write if it throws:

```ts theme={null}
async transfer(from: string, to: string, cents: number) {
  this.ctx.storage.transactionSync(() => {
    this.ctx.storage.sql.exec(`UPDATE accounts SET cents = cents - ? WHERE id = ?`, cents, from);
    this.ctx.storage.sql.exec(`UPDATE accounts SET cents = cents + ? WHERE id = ?`, cents, to);
    // throw here — e.g. on an overdraft check — and neither UPDATE is applied
  });
}
```

The callback returns a value — `transactionSync` passes it through, so you can read
back a result computed inside the transaction.

Two rules, both enforced by the runtime:

* **`transactionSync` is the only transaction API.** Raw transaction-control statements —
  `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` — are rejected by `exec()` with
  `SqlTransactionControlError`, anywhere in a statement batch. (An open transaction left
  dangling across method calls would have no owner; the callback shape makes that
  impossible.)
* **The callback must be synchronous.** Passing an `async` function throws
  `SqlAsyncTransactionError` and commits nothing — the transaction commits when the
  callback returns, so statements after an `await` would run outside it. Do async work
  before or after, not inside.

Because a single actor instance processes one method at a time, you never contend with
another writer inside your own database — the actor *is* the lock.

## Bulk Writes

Each standalone `exec` write commits and flushes to disk on its own, which is slow in a
loop. Wrap a batch in one `transactionSync` and it commits (and flushes) once:

```ts theme={null}
this.ctx.storage.transactionSync(() => {
  for (const row of rows) this.ctx.storage.sql.exec(`INSERT INTO t(v) VALUES (?)`, row);
});
```

The difference is large: thousands of individual auto-commit inserts take seconds; the same
inserts inside one transaction take tens of milliseconds. Batch anything hot.

## Indexes

The database holds one entity's working set, but scans still cost time and — as it grows —
memory. Add an index on any column you filter or order by frequently:

```ts theme={null}
this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS orders_by_sku ON orders(sku)`);
```

## Limits

* **1 GB per actor database.** Enforced by the engine — writes past it fail.
* **2 MiB per bound value.** A bind larger than 2 MiB (2,097,152 bytes) is rejected
  with `SqlParameterTooLargeError` before the statement runs. There is no cap on output
  row size.
* **Up to 32,766 bound parameters** in a single statement (SQLite's variable limit).
* **Integers come back as JavaScript `number`.** SQLite stores 64-bit integers, but `exec`
  returns integer columns as JS numbers — the same design as Cloudflare's SQLite storage and
  D1, neither of which returns `BigInt` from the query API. A value beyond
  `Number.MAX_SAFE_INTEGER` (2^53 − 1, i.e. `9007199254740991`) can be written and compared
  in SQL, but **reading that column throws** `RangeError: Value is too large to be represented
  as a JavaScript number` — a loud failure rather than a silently less-precise number. Store
  large ids, bitmasks, or nanosecond timestamps as `TEXT` (or `BLOB`), or keep integers within
  the safe range.
* **Foreign keys are ON** — a `REFERENCES` violation raises `FOREIGN KEY constraint failed`.
* Failed statements (syntax errors, constraint violations) throw from `exec`; the actor
  keeps running, so catch and handle them like any other error.

## SQL or Key/Value?

Both live under `this.ctx.storage`, are durable and strongly consistent, and can be used
together in the same actor.

* **[Key/value](/docs/edge-compute/stateful-actors/guides/storage/key-value)**
  (`get`/`put`/`list`): simple keyed state, prefix listing, atomic multi-key updates —
  reach for it by default when a lookup by key is all you need.
* **SQL** (`ctx.storage.sql`): relational shape, aggregates, `GROUP BY`, secondary indexes,
  and joins *within one actor*. Reach for it when a keyed lookup isn't enough and you'd
  otherwise scan.

## Related

* [Key-Value storage](/docs/edge-compute/stateful-actors/guides/storage/key-value) — the `get`/`put`/`list` surface
* [SQL Databases](/docs/edge-compute/sqldb) — the shared, standalone SQLite database, for data more than one caller reads
* [Actor Storage reference](/docs/edge-compute/stateful-actors/api-reference/storage) — the full `ctx.storage` contract
* [Execution model](/docs/edge-compute/stateful-actors/concepts/execution-model) — why `exec` needs no lock

***

### WebSockets

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets.md)

WebSocket support for Stateful Actors is in **beta**. APIs and limits may change before general availability.

A client opens one WebSocket. Your function runs **once, at the handshake** — check the
upgrade, authenticate, decide which actor owns this connection — and hands off. From then
on the **actor owns the live socket**: each inbound frame is dispatched like a method
call — one at a time per instance, serialized with RPCs and
[alarms](/docs/edge-compute/stateful-actors/alarms) — durable
state is ordinary `ctx.storage`, and replies stream straight back with `ws.send()`.

That shape removes the plumbing a chat, agent, or live-session backend usually accretes.
When the connection tier and the state tier are separate fleets, every message crosses a
pub/sub bridge on the way in and needs a reverse channel to find whichever host holds the
socket on the way out. Here the socket and the state live in the same single-threaded
instance — a message handler is a plain method body that reads storage, writes storage,
and sends.

Any actor class can terminate WebSockets — declaring the `webSocket()` method is the
whole opt-in; there is no flag to enable, in `telnyx.toml` or anywhere else. Actor names
used with sockets must be printable ASCII with no leading or trailing whitespace, and any
header values your function forwards must be printable ASCII today.

## The Two Halves

|              | Runs                                      | Responsible for                                                                                               |
| ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Function** | once per connection, at the handshake     | reject non-upgrades (`426`), authenticate, rate-limit, resolve `idFromName`, stamp what it learned as headers |
| **Actor**    | once per frame, and per close/error event | per-message logic, durable state, replies                                                                     |

The boundary is strict: **per-message logic never lives in the function.** After the
handshake the function is done — frames are delivered only to the actor. Everything the
function learned rides the forwarded request as headers, so the socket reaches the actor
already authenticated. The `req` the actor sees carries only those application headers;
transport and handshake headers (`Upgrade`, `Sec-WebSocket-*`) are stripped.

The smallest complete pairing:

```ts theme={null}
// The function half — runs once, at the handshake.
export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
      return new Response("expected websocket", { status: 426 });
    }
    const user = await authenticate(req);      // per-connection work — done once, here
    if (!user) return new Response(null, { status: 401 });

    const headers = new Headers(req.headers);
    headers.set("x-user", user.id);            // what you learned rides as headers
    return env.ROOM.idFromName(roomFor(req)).fetch(new Request(req, { headers }));
  },
};

// The actor half — owns the live socket from here on.
export class Room extends StatefulActor {
  async webSocket(ws: WebSocket, req: Request): Promise<void> {
    const user = req.headers.get("x-user");    // already authenticated
    ws.on("message", async (data) => {         // messages dispatch one at a time
      const n = ((await this.ctx.storage.get<number>("n")) ?? 0) + 1;
      await this.ctx.storage.put("n", n);
      ws.send(`${user} #${n}: ${data}`);       // streams straight back
    });
  }
}
```

(`authenticate` and `roomFor` stand in for your auth and routing; a complete, runnable
version is [below](#a-chat-room-end-to-end).)

The actor opts in by defining one optional method:

```ts theme={null}
async webSocket(ws: WebSocket, req: Request): Promise<void>
```

`ws` has Node `ws` socket semantics — `ws.on("message", (data, isBinary) => ...)`,
`ws.send(data)`, `ws.close(code, reason)` — not a browser socket and not Cloudflare's
`WebSocketPair`/`accept()`. Text and binary frames both work. An actor with no
`webSocket()` method closes incoming sockets with code `1011`.

## A Chat Room, End to End

One module exports both halves. The `ChatRoom` actor holds the room's messages and its
live sockets; the default export is the front door.

```toml theme={null}
# telnyx.toml
name = "chat"
main = "src/index.ts"
compatibility_date = "2026-05-01"

[[actors]]
binding = "ROOM"
type    = "ChatRoom"
```

```ts theme={null}
// src/index.ts
import { StatefulActor, type ActorNamespace } from "@telnyx/edge-runtime";
import type { WebSocket } from "ws";
// `ws` is a transitive dependency of @telnyx/edge-runtime. With npm it resolves
// automatically; with pnpm or other strict layouts, install @types/ws yourself.

export class ChatRoom extends StatefulActor {
  async webSocket(ws: WebSocket, req: Request): Promise<void> {
    // The front door authenticated this connection and stamped x-user; by the
    // time the socket reaches the actor, it is already authenticated.
    const user = req.headers.get("x-user") ?? "anonymous";

    ws.send(JSON.stringify({ type: "welcome", user, present: this.ctx.count() }));

    ws.on("message", async (data, isBinary) => {
      if (isBinary) return; // this room speaks text JSON

      // Parse defensively: an uncaught throw in a handler closes the
      // socket with code 1011.
      let text: string;
      try {
        ({ text } = JSON.parse(String(data)) as { text: string });
      } catch {
        ws.send(JSON.stringify({ type: "error", error: "frames must be JSON" }));
        return;
      }

      // Durable read-modify-write. Messages dispatch one at a time per
      // instance, so this never races another socket's frame — no lock, no
      // transaction.
      const seq = ((await this.ctx.storage.get<number>("seq")) ?? 0) + 1;
      await this.ctx.storage.put("seq", seq);
      await this.ctx.storage.put(`msg:${seq}`, { user, text, at: Date.now() });

      // Fan out to every live socket on THIS room, then ack the sender.
      const delivered = this.ctx.broadcast(
        JSON.stringify({ type: "message", seq, user, text }),
      );
      ws.send(JSON.stringify({ type: "sent", seq, delivered }));
    });

    // Close and error events go through the same single-threaded dispatch;
    // a storage write in a close handler still commits. The runtime removes the socket from
    // count()/broadcast() on its own — register these only for your own
    // bookkeeping.
    ws.on("close", async (code) => {
      await this.ctx.storage.put("lastLeave", { user, code, at: Date.now() });
    });
    // Keep an error listener so transport errors land in your logs instead of
    // vanishing; the runtime already tears the socket down on error.
    ws.on("error", (err) => console.error("ws error", err));
  }

  // Plain RPC on the same actor — same instance, same one-at-a-time dispatch.
  async messageCount(): Promise<number> {
    return (await this.ctx.storage.get<number>("seq")) ?? 0;
  }
}

interface Env {
  ROOM: ActorNamespace;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const url = new URL(req.url);
    const match = url.pathname.match(/^\/rooms\/([^/]+)$/);
    if (!match) return new Response("not found", { status: 404 });

    if (req.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
      return new Response("expected websocket", { status: 426 });
    }

    // Browser WebSocket clients cannot set request headers, so credentials
    // ride the URL. This code runs once per connection — the right place to
    // verify a signed token. The example only checks shape: forwarded header
    // values must be printable ASCII, and should be bounded.
    const user = url.searchParams.get("user");
    if (!user || !/^[\x20-\x7e]{1,128}$/.test(user)) {
      return new Response("missing or malformed ?user=", { status: 401 });
    }

    // Attach what the front door learned; overwrite anything client-supplied.
    const headers = new Headers(req.headers);
    headers.set("x-user", user);

    // Hand off: the actor named by the room owns the socket from here on.
    return env.ROOM.idFromName(match[1]!).fetch(new Request(req, { headers }));
  },
};
```

Connect with any WebSocket client:

```bash theme={null}
websocat "wss://<your-function-host>/rooms/lobby?user=alice"
{"type":"welcome","user":"alice","present":1}
{"text":"hello"}
{"type":"message","seq":1,"user":"alice","text":"hello"}
{"type":"sent","seq":1,"delivered":1}
```

Every socket opened on `/rooms/lobby` lands on the same `ChatRoom` instance;
`/rooms/standup` is a different instance with its own `seq`, its own messages, and its
own sockets. `ctx.count()` and `ctx.broadcast()` see only this instance's sockets — a
broadcast never crosses actor ids, and a socket never moves between them.

## Sending to Some Sockets, Not All

`ctx.broadcast()` is all-or-nothing: one frame to every socket on the instance. There is
no built-in way to address a subset — no socket list to filter, no per-socket tags.
"Everyone except the sender" and "just this user" are yours to build, and on a
single-threaded instance the whole mechanism is one `Map`: filled in `webSocket()`,
pruned in the close listener.

```ts theme={null}
export class Room extends StatefulActor {
  // Keyed by socket, not by user: one user may hold several sockets (two tabs).
  private peers = new Map<WebSocket, { user: string }>();

  async webSocket(ws: WebSocket, req: Request): Promise<void> {
    const user = req.headers.get("x-user") ?? "anonymous";
    this.peers.set(ws, { user });
    // Close events dispatch one at a time like everything else, so this
    // delete never races a message handler mid-iteration.
    ws.on("close", () => this.peers.delete(ws));

    ws.on("message", (data) => {
      // Everyone except the sender:
      for (const peer of this.peers.keys()) {
        if (peer !== ws) peer.send(`${user}: ${data}`);
      }
    });
  }

  // One named user — every socket they have open. Call it from a message
  // handler that routes a direct-message frame, or expose it as an RPC.
  sendTo(user: string, data: string): number {
    let sent = 0;
    for (const [peer, meta] of this.peers) {
      if (meta.user === user) {
        peer.send(data);
        sent++;
      }
    }
    return sent;
  }
}
```

The map is a plain instance field, and here — unlike almost everywhere else on the
platform — that is correct, with nothing written to `ctx.storage`. Sockets and instance
memory share a lifetime: an open socket keeps the instance resident, and the events that
replace an instance — a redeploy, an infrastructure restart — sever its sockets too. A
fresh instance therefore starts with no sockets and an empty map, never one without the
other; there is nothing to persist and nothing to rebuild. What should outlive the
connection — who was here, what they said — goes in `ctx.storage` as usual.

## Messages Are Single-Threaded, Like Everything Else

An inbound frame is dispatched like a method call, under the execution model's
["one call at a time" guarantee](/docs/edge-compute/stateful-actors/concepts/execution-model):
while a message handler runs, frames from other sockets on the same instance, RPC calls,
and the alarm all wait. That is exactly what makes the chat room's read-modify-write on
`seq` safe with any number of concurrent senders.

The consequences:

* **The 30-second method budget applies to each message handler.** A handler that throws
  or exceeds it closes the socket with code `1011`.
* **`ws.send()` is immediate — and not held for durability.** A method's return
  value is held until your storage writes are durable; a sent frame is not. If a handler
  sends and then fails, the client keeps a frame describing state that never committed.
  If clients must act only on committed state, make frames re-derivable from storage on
  reconnect — the chat room's durable `seq` makes gaps and replays detectable.
* **Keep per-message work bounded.** Frames that arrive while a handler runs queue, up
  to 256 events or 1 MiB; overflow closes the socket with `1013` (back off before
  reconnecting), and a single inbound frame over 1 MiB closes it with `1009`. Don't
  stream minutes of work from one handler — chunk it across messages, or drive it from
  an alarm. `ws.send()` itself never blocks the handler.
* **High-frequency senders should coalesce.** Every frame costs a full dispatch — one
  turn of the instance's single thread, one queue slot — so a client emitting a stream
  of tiny messages (sensor readings, cursor positions, game state) spends the queue on
  frame count, not bytes: at 100 messages a second, a single handler that stalls for
  three seconds is enough to overflow the 256-event queue and close the socket with
  `1013`. Batch logical messages into one frame — an array, flushed on a short timer or
  a count threshold — and loop over the batch in the handler. Fifty readings in one
  frame cost one dispatch and one queue slot instead of fifty; just keep the batch under
  the 1 MiB frame cap.

An open socket also keeps the actor in memory for the duration of the connection.

## Connections End; State Doesn't

Every connection ends. Today a connection lives at most about five minutes from the
handshake — even on a socket actively exchanging frames — and the cutoff surfaces as an
abnormal close (code `1006`, no close frame). This cap will be raised in a future update.
Function redeploys and infrastructure restarts sever sockets the same
way; an actor-side restart instead closes each socket gracefully with code
`1001`; a deliberate close from either side is code `1000`.

Reconnecting is the client's job: back off, reopen, rejoin the same name. `idFromName`
routes the new socket back to the same actor, and everything in `ctx.storage` survived —
the room's messages and `seq` are intact; only the socket is new. A reconnect is a new
socket on the actor, never a resumption of the old one.

The full close-code table and reconnect guidance are on
[Connection Lifecycle](/docs/edge-compute/stateful-actors/websockets/lifecycle).

## Push Without an Inbound Frame

An actor runs only when something calls it — a frame, an RPC, or an alarm; time passing
alone runs nothing. To push to connected clients without waiting for them to send
anything, let the [alarm](/docs/edge-compute/stateful-actors/alarms) make the call and
fan out with `ctx.broadcast()`:

```ts theme={null}
export class Ticker extends StatefulActor {
  async webSocket(ws: WebSocket, req: Request): Promise<void> {
    // First socket arms the loop; later sockets just join the fan-out.
    if ((await this.ctx.storage.getAlarm()) === null) {
      await this.ctx.storage.setAlarm(Date.now() + 3_000);
    }
  }

  async alarm(): Promise<void> {
    this.ctx.broadcast(JSON.stringify({ type: "tick", at: Date.now() }));
    if (this.ctx.count() > 0) {
      await this.ctx.storage.setAlarm(Date.now() + 3_000); // re-arm while anyone listens
    }
  }
}
```

The alarm handler is an ordinary method call, so it can read storage, write storage, and send —
a message handler on one socket can likewise broadcast to all of them, as the chat room
does.

## Outbound Requests

Egress from an actor is ordinary Node: the global `fetch` and the global `WebSocket`
client both work from inside any handler — call a model API and stream its output back
over the socket, or dial an upstream WebSocket, exchange frames, and close it. The
dispatch rules apply unchanged: outbound work inside a message handler holds the
instance's only thread and runs under the 30-second budget, so ship interim results with
`ws.send()` as they arrive, and chunk anything long across messages or drive it from an
[alarm](/docs/edge-compute/stateful-actors/alarms).

## Next Steps

* [Runtime API](/docs/edge-compute/stateful-actors/api-reference) — `webSocket()`, `ctx.count()`, `ctx.broadcast()`
* [Connection Lifecycle](/docs/edge-compute/stateful-actors/websockets/lifecycle) — duration budget, close codes, reconnect strategy
* [Execution Model](/docs/edge-compute/stateful-actors/concepts/execution-model) — the four guarantees behind single-threaded dispatch
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — the delivery contract behind the push pattern
* [Quick Start](/docs/edge-compute/stateful-actors/quick-start) — scaffold and deploy your first actor

***

### Connection Lifecycle & Limits

> Source: [https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets/lifecycle.md](https://developers.telnyx.com/docs/edge-compute/stateful-actors/websockets/lifecycle.md)

WebSocket support for Stateful Actors is in **beta**. APIs and limits may change before general availability.

One contract underlies everything on this page: **every connection ends**. A redeploy severs it, the duration budget expires it, a failing handler closes it. Build for that from the start — treat the socket as disposable transport and the actor as the durable thing. Reconnecting is the client's job; `idFromName` with the same name routes the new socket back to the same actor, and everything in `ctx.storage` is exactly where the last connection left it. A client that reconnects with backoff and re-syncs on open loses nothing but the socket.

## How a Connection Is Established

Your function's `fetch` runs **once**, at the handshake — check the `Upgrade` header, authenticate, pick the actor, forward. It never sees another byte of the connection; per-message logic lives in the actor's `webSocket()` handler. The full front-door pattern is on the [WebSockets](/docs/edge-compute/stateful-actors/websockets) page.

What a client can rely on at and after the handshake:

* **The upgrade must be a `GET`.** A non-`GET` request with an `Upgrade` header is refused with HTTP `400` — no handshake starts.
* **The first client-offered subprotocol is echoed.** Offer `["chat.v2", "chat.v1"]` and the connection is accepted with `chat.v2`. The actor cannot select among the offers today; it observes the echoed value as `ws.protocol`. Put your preferred protocol first.
* **Compression is never negotiated.** A `permessage-deflate` offer is accepted without the extension; frames flow uncompressed.
* **Text and binary frames both work.** The handler's `message` listener receives `(data, isBinary)`; binary frames round-trip byte-identically.
* **Protocol-level pings are answered by the platform**, payload echoed. You don't write keepalive code on the actor side.

## How Connections End

| Code          | Meaning                                                                                                                                                                                                                                                         | What the client should do                                                                                                                                                                                                                                                                 |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `1000`        | Normal close — either side asked for it (`ws.close(1000, ...)` on the actor, or the client).                                                                                                                                                                    | Nothing. Don't reconnect.                                                                                                                                                                                                                                                                 |
| `1005`        | No close code received — a bare `ws.close()` or browser close with no code argument surfaces as `1005` at the peer. Treat as deliberate if you control both sides; otherwise indistinguishable from an abnormal drop.                                           | Reconnect if the close was not expected; otherwise no action.                                                                                                                                                                                                                             |
| `1001`        | Actor going away — a graceful drain before the actor restarts or moves. The close frame arrives with reason `actor going away`; a client that never completes the close handshake is dropped a few seconds later.                                               | Reconnect promptly; the actor comes back under the same name.                                                                                                                                                                                                                             |
| `1006`        | Abnormal — the connection was severed with no close frame: the duration budget expired, a function was redeployed, or infrastructure between your client and the actor restarted. An actor-side restart is not on this list — it closes gracefully with `1001`. | Reconnect with backoff.                                                                                                                                                                                                                                                                   |
| `1009`        | An inbound frame exceeded the 1 MiB cap.                                                                                                                                                                                                                        | Fix the client — chunk large payloads. Reconnecting without fixing it hits the same wall.                                                                                                                                                                                                 |
| `1011`        | A message handler threw or exceeded the 30-second method budget. Also sent when the actor class has no `webSocket()` handler at all (reason: `actor has no webSocket handler`).                                                                                 | Reconnect, but treat repeats as a server-side bug to fix, not a transient.                                                                                                                                                                                                                |
| `1013`        | Event queue overflow — frames arrived faster than handlers drained them (256 events / 1 MiB queued). The actor is overloaded.                                                                                                                                   | Back off **longer** than for `1006` before reconnecting, and slow your send rate. High-frequency clients should [batch messages into fewer frames](/docs/edge-compute/stateful-actors/websockets#messages-are-single-threaded-like-everything-else) — the queue counts events, not bytes. |
| `4000`–`4999` | Application-defined — the RFC 6455 private-use range. The platform doesn't use these codes; one of them means your own actor called `ws.close()`, and the code and reason arrive exactly as the actor sent them.                                                | Whatever your protocol assigned it — kicked, room closed, session expired. It's a deliberate close: act on it, don't blind-retry it.                                                                                                                                                      |

**Today, a connection lives at most about five minutes.** The total-duration budget applies even to a socket actively exchanging frames; the cutoff surfaces as close code `1006` with no close frame. This is a current limit, not the contract — it will be raised in a later release. Until then, design clients to ride through a reconnect every few minutes as a matter of course.

There is no separate idle kill below that budget — a socket that goes quiet for 90 seconds and then sends a frame still gets that frame delivered and handled. The duration budget is what ends it.

That guarantee covers the platform's half of the path only. NATs, corporate proxies, and load balancers between your client and Telnyx run their own idle timers, and any of them can drop a quiet connection long before the duration budget would — often silently, leaving the client holding a socket that looks open but delivers nothing. No close event fires for a drop like that, so reconnect logic alone never notices it; only traffic does. If your clients sit quiet for long stretches, send a periodic heartbeat: non-browser clients can use a protocol-level ping, which the platform answers, and browser `WebSocket` clients — which cannot send pings — should use a small application-level frame the `webSocket()` handler replies to. A heartbeat frame is one more frame through the same single-threaded dispatch, so keep it small and infrequent, and give your client library's own idle or read timeouts the same review.

**Function deploys sever live sockets.** Shipping a new version of your function drops every connection it carries as `1006`. An actor-side restart, by contrast, drains gracefully: each socket receives a `1001` close frame before the actor goes down. Either way the connection ends — this is another reason reconnect logic is not optional: your own deploys exercise it.

## Reconnecting

A complete client. It reconnects on every close except the deliberate ones — `1000`, or an application-defined `4xxx` — backs off exponentially, backs off harder on `1013`, and re-syncs on open — the socket is new even though the actor and its storage are not.

```ts theme={null}
function connect(room: string, attempt = 0): void {
  const ws = new WebSocket(
    `wss://<your-function-host>/rooms/${room}?user=me`,
  );
  let openedAt = 0;

  ws.onopen = () => {
    openedAt = Date.now();
    // Re-request whatever context this client needs. Durable state picked
    // up where it left off — a counter in ctx.storage that read 2 before
    // the drop reads 3 after the next message, not 1.
    ws.send(JSON.stringify({ op: "sync" }));
  };

  ws.onmessage = (ev) => {
    // ... application protocol ...
  };

  ws.onclose = (ev) => {
    if (ev.code === 1000) return; // done on purpose
    if (ev.code >= 4000) return; // app-defined — deliberate; act on it, don't retry it
    // Reset the backoff only after a connection proved stable — an actor
    // that accepts the handshake and then dies must still back off.
    if (openedAt > 0 && Date.now() - openedAt > 30_000) attempt = 0;
    const base = ev.code === 1013 ? 2_000 : 250; // overloaded → back off harder
    const delay = Math.min(base * 2 ** attempt, 30_000) + Math.random() * 250;
    setTimeout(() => connect(room, attempt + 1), delay);
  };
}

connect("standup");
```

Reconnect to the **same name**. `idFromName("standup")` routes to the same actor every time, so the durable sequence counters, history, and alarms of the conversation are all still there. What does not survive is anything held only in the actor's instance fields or in the old socket itself — the same rule as the rest of the platform: only `ctx.storage` is real. See [Lifecycle & placement](/docs/edge-compute/stateful-actors/concepts/lifecycle).

## Limits

| Limit                                | Value                                                                                                                 | On violation                  |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
| Inbound frame size                   | 1 MiB                                                                                                                 | Close `1009`                  |
| Event queue while a handler runs     | 256 events / 1 MiB                                                                                                    | Close `1013`                  |
| Per-message method budget            | 30 seconds                                                                                                            | Close `1011`                  |
| Connection duration                  | \~5 minutes today (expected to increase)                                                                              | Close `1006`, no close frame  |
| Socket scope                         | One actor id — `ctx.count()` and `ctx.broadcast()` reach only this id's sockets; sockets never carry over between ids | —                             |
| Actor names used with sockets        | Printable ASCII, no leading/trailing whitespace, today                                                                | Connection fails to establish |
| Header values your function forwards | Printable ASCII today                                                                                                 | Connection fails to establish |
| Outbound frame size                  | No platform cap — unsent bytes wait in the actor's memory until the client's connection drains them                   | —                             |

The queue limit is a consequence of single-threaded dispatch: an inbound frame is handled like a method call — one at a time per instance, serialized with RPCs and alarms. While one handler runs, further frames queue; a slow handler with fast senders overflows the queue and the socket closes `1013`. Keep per-message work well under the 30-second budget — the queue bound is sized for handlers that finish in milliseconds, not tens of seconds.

The same serialization sets the practical ceiling on how many sockets one instance should carry. The table has no socket-count row because the working bound is not a count — it is aggregate frame rate. An instance handles one frame at a time from all of its sockets combined: at one millisecond of handler work per frame, the whole instance drains at most on the order of a thousand inbound frames per second, and a `ctx.broadcast()` inside a handler writes one frame to every connected socket, so fan-out work grows with the audience. The 256-event queue absorbs a burst of at most a couple hundred frames, not thousands of senders talking at once. When one name's combined rate approaches what its handlers can drain, partition the workload across names — a room per `idFromName`, a shard per topic or region — so each instance coordinates only the sockets whose combined traffic it can serialize. An actor is a unit of coordination, not a unit of capacity.

The outbound direction is the mirror image, and it is not policed. `ws.send()` never blocks the handler, and the platform never drops or coalesces frames on a live connection — anything the client hasn't drained yet waits in the actor's memory, and `ws.bufferedAmount` (standard Node `ws`) reports how much is queued on a socket. Against a responsive client that number stays near zero; against a stalled one — a backgrounded tab, a dead radio link — every `ctx.broadcast()` tick adds another payload to the stalled socket's queue. Bound what one slow consumer can cost: keep broadcast payloads small, and when updates outpace a client, send the latest state rather than queueing every delta — durable state in `ctx.storage` means a client that fell behind can re-sync from a snapshot instead of replaying a backlog. A socket whose `bufferedAmount` only grows is gone in every way that matters; close it, and the reconnect-and-re-sync logic every client here needs anyway will pick it back up.

## What Holds the Actor in Memory

While a connection is open its actor stays resident — the actor is not deactivated between frames — and under today's duration budget a connection never lives long enough for the idle eviction described in [Lifecycle & placement](/docs/edge-compute/stateful-actors/concepts/lifecycle) to be a factor. The cost model follows: a million idle actors are cheap; a million actors each holding an open socket are a million resident activations.

Teardown is orderly on the actor side. `close` and `error` listeners run under the same single-threaded dispatch as any other event, and a `ctx.storage` write inside a `close` handler commits — the instance stays resident until those final handlers finish, not merely until the raw socket drops. Recording "last seen" in a close handler is safe. After the last socket's close handler finishes, the actor is an ordinary idle actor again: evictable, and revivable by the next `idFromName` call — or by an [alarm](/docs/edge-compute/stateful-actors/alarms) it set while the connection was up.

## Next Steps

* [WebSockets](/docs/edge-compute/stateful-actors/websockets) — the front-door pattern, the `webSocket()` handler, and how messages dispatch
* [Lifecycle & placement](/docs/edge-compute/stateful-actors/concepts/lifecycle) — eviction and restart for actors generally
* [Alarms](/docs/edge-compute/stateful-actors/alarms) — deferred work that outlives any connection

***
