Introduction

Typed state behind a mailbox. Each instance processes one message at a time, and each handler ends in one atomic commit.

The model

An actor is state behind a mailbox: you send an instance typed events, and its handlers process them one at a time. Between messages an instance is rows in your store, not a process. Every instance is an ordinary A2 session, so durability, retries, history, and devtools hold for actors too.

Entry points: experimental-a2/actor on the server, experimental-a2/actor/client anywhere, and experimental-a2/actor/react for the hook.

Define one

vault.ts
import { NonRetriableError } from 'experimental-a2'
import { actor } from 'experimental-a2/actor'

interface Vault {
  state: { balance: number }
  events: {
    deposit: { amount: number }
    withdraw: { amount: number }
  }
}

export const vault = actor<Vault>({
  name: 'vault',
  state: { balance: 0 },
  handlers: {
    deposit: (ctx, input) => {
      ctx.state.balance += input.amount
    },
    withdraw: (ctx, input) => {
      if (ctx.state.balance < input.amount) {
        throw new NonRetriableError('insufficient funds')
      }
      ctx.state.balance -= input.amount
    },
  },
})

Pass the protocol explicitly as the type argument. It declares types, not schemas: handlers are trusted server code, and a missing handler, an extra one, or a wrong input shape fails to compile. ctx.state is a mutable draft, committed atomically when the handler returns. name is identity (it prefixes storage keys), and state seeds new instances.

When an event can arrive from the wire, guard its input in the handler's first lines and throw NonRetriableError on bad shapes.

actor also accepts createServer's store, scheduler, and telemetry options, with the same development default store; see Stores and Schedulers. A protocol can also declare presence.

Call it

// anywhere on the server:
import { vault } from '@/vault'

const savings = vault.actor('savings')

const { state, index } = await savings.call.deposit({ amount: 100 })
// state reflects this deposit, and every message before it

call awaits the answer: the state after the handler ran and the log index of that commit. vault.actor(id) is a handle; nothing exists until the first message. Concurrent calls from any number of invocations serialize on the instance's lane, and each answer reflects its own message; see Concurrency.

Refusals

// anywhere on the server:
import { ActorRefusedError } from 'experimental-a2/actor'
import { vault } from '@/vault'

try {
  await vault.actor('savings').call.withdraw({ amount: 1_000_000 })
} catch (error) {
  if (error instanceof ActorRefusedError) {
    // the answer was no: state unchanged, message settled
  }
}

Throwing NonRetriableError refuses the message. A refusal is an answer, and it is all or nothing: no state change, no buffered sends, no retry. The message settles, and the caller receives ActorRefusedError.

  • Any other throw retries the message with the usual budget and backoff while the instance's lane waits; see Errors.
  • A crash re-runs the handler from the pre-message state. ctx.id is the message id, stable across re-runs: use it as the idempotency key for external I/O.
  • A clear by handle.session.clear() before the answer rejects the call with an A2Error whose code is SESSION_CLEARED. The handler may already have run. Over HTTP it's a 410.

Send, don't wait

// anywhere on the server:
import { vault } from '@/vault'

const { id } = await vault.actor('savings').send.deposit({ amount: 25 })
// durably queued; the handler runs on the instance's lane

send validates and appends the event durably without waiting for the handler: same mailbox, same ordering. Pass { id } in the options to make a retried send idempotent.

Read without queueing

// anywhere on the server:
import { vault } from '@/vault'

const { state } = await vault.actor('savings').state()

A snapshot read: it never queues behind pending messages, and a new instance reads as its seed state.

State is remembered, not recomputed

Every commit appends a state event, and reads fold them by last write wins. Unlike reducers, nothing is recomputed, so rewriting a handler never changes what a past commit means. State must be plain JSON: a Date, a Map, or NaN fails the commit loudly.

  • Old instances meet new code. After a deploy, default new fields defensively (ctx.state.pending ??= {} as a first line).
  • The seed applies to new instances only. Changing state does not migrate existing instances.

The log is still there

handle.session is the instance's underlying session (history, streams, scheduling). The definition exposes its assembled contract, server, and reducer, and devtools show the full mailbox. Event names starting with a2. are reserved for the actor's own bookkeeping.