Core

Every entry point, and the isomorphic experimental-a2 core: contracts, reducers, IDs, and the event shape.

Entry points

Entry pointShipsPeer dependency
experimental-a2contract, A2Error: isomorphic corenone
experimental-a2/servercreateServer: implement a contractnone
experimental-a2/clientcreateClient: framework-agnostic session clientnone
experimental-a2/reactcreateReactreact
experimental-a2/aiagentContract, handlerContext, inputs, and the AI reducer: isomorphicai
experimental-a2/ai/serveragent: an agent's loop as an A2 serverai
experimental-a2/ai/fxfx: the FX harness for agent()ai, libfx
experimental-a2/ai/pipi: the Pi harness for agent()ai, @earendil-works/pi-coding-agent, @earendil-works/pi-ai
experimental-a2/ai/clientcreateAgentClient: contract-free agent clientai
experimental-a2/ai/reactuseAgentai, react
experimental-a2/store-postgrespostgres: Postgres store backendpg (or inject a client)
experimental-a2/store-redisredis: Redis Streams store backend, push-native streamingioredis (or inject a client)
experimental-a2/store-redis-httpredisHttp: the same Redis store over provider REST APIs (Upstash)none
experimental-a2/store-sqliteSQLite store backendnone
experimental-a2/store-memoryin-memory store backendnone
experimental-a2/scheduler-vercelvercelQueues: recovery and timers@vercel/queue
experimental-a2/scheduler-qstashqstash: signed recovery and timers@upstash/qstash
experimental-a2/cache-indexeddbindexedDb browser cachenone
experimental-a2/otelotel telemetry adapter@opentelemetry/api

experimental-a2 imports none of the backends, and a browser-bundle test in CI enforces it. Importing a backend's entry point, such as experimental-a2/store-postgres, is what pulls that backend in; core never puts one in your client bundle.

experimental-a2

a2.contract(options)

a2.contract(options: {
  name: string
  events: Record<string, StandardSchemaV1>
  presence?: Record<string, StandardSchemaV1>
  reducer?: { name: string; initialState: S; stateSchema?: StandardSchemaV1; fold: (state: S, event: Event) => S }
}): Contract

Defines one kind of lifecycle. The result is a plain, importable, isomorphic value: the server implements it, reducers derive from it, and the browser types its pushes off it. See Events.

OptionMeaning
nameUnique per app; identifies the contract in storage and queue messages. May not contain the control character U+001F.
eventsEvent type to payload schema. Types may not start with a2., which A2 reserves for its own events, such as a2.cleared.
presenceOptional. Field to schema for one participant's ephemeral state. Replicated last-writer-wins, never stored in the log, invisible to reducers. See Presence.
reducerOptional. The one reducer the contract publishes to clients; an inline definition includes fold. See reducer(options).

Schemas are Standard Schema: Zod, Valibot, ArkType, anything that implements it. Validators must be synchronous; async ones are rejected at definition time. The validated output is what's stored.

contract.batch(...events)

Returns the same events as a readonly tuple, preserving each literal type in a heterogeneous handler-returned array. A2 validates them when the handler completes. See Handlers.

reducer(options)

reducer(options: {
  contract: Contract
  name: string                // identity; keys cached snapshots
  initialState: S             // the seed (schema input, when stateSchema is set)
  stateSchema?: StandardSchemaV1 // declares S, checks initialState, guards cached folds
}): { fold(fn: (state: S, event: Event) => S): Reducer<S> }

Builds a standalone reducer for server reads or for the contract's reducer field. See State.

  • The first call fixes the state and event types, so .fold() receives concrete ones. Literal unions in your state survive with zero annotations.
  • name keys cached snapshots, so two reducers never fight over a cache entry. Rename it when the fold's logic changes: a rename (or a stateSchema mismatch, when present) invalidates, and the next read refolds from raw events.

A2Error

The shared base for A2 failures, discriminated by code. The session client exposes its A2ClientError subclass. See Errors.

idempotentId(...parts)

idempotentId(...parts: string[]): Promise<string> derives a stable UUIDv8 from an ordered tuple of strings. It performs no I/O and runs on the server and in the browser.

shared/request-id.ts
import { idempotentId } from 'experimental-a2'

export const id = await idempotentId('orders', 'shop-7', 'request-42')
  • Pass the result as an event's id when retries should converge on the same event.
  • The same tuple produces the same ID across runtimes and A2 versions.
  • String boundaries, order, and case matter; strings are not normalized. Empty strings and an empty tuple are valid.
  • Every argument has the same role; a prefix such as 'orders' is a namespace by convention. Include enough context to distinguish operations across contracts and sessions: event IDs are globally unique within a store.
  • A2 preserves explicit IDs and does not change its automatic ID generation. Changing an existing recipe changes retry identity, so retries of already committed requests must keep their original IDs.

SessionCleared

An Event whose type is 'a2.cleared' and whose payload is null: the marker a clear leaves in session.stream(). It never reaches handlers or history(). Comparing event.type with that string narrows a stream item to SessionCleared. The session client handles the marker itself, so it matters only when you read the server stream yourself.

Event

type Event = {
  id: string
  type: string
  payload: unknown // typed by the contract's schema for `type`
  index: number // position in the session's log, from 1
  sessionId: string
  createdAt: Date
}