TypeScript SDK
Responsibility: define the non-Effect client experience.Authority: API design.
Owner role: SDK. Change policy: a change requires compatibility review against docs/api/versioning.md.
@rikalabs/akter/client is the browser-safe Promise client. It is derived from the same actor definitions, runtime schemas, errors, and OpenAPI surface as the Effect API; it is not a second runtime. It also exports Inspection, the schemas of the runtime’s inspection responses, so a browser tool decodes what the inspector serves without restating its shape.
X.client({ baseUrl, headers, timeoutInMs, fetch, commandIds, offline }) creates a client. Its get and create accessors follow the actor’s key. Commands and queries return Promises, event feeds and server streams are AsyncIterable, and connections combine an async frame stream with typed send and close operations. A connection’s frames arrive as { frame, cursor?, event? } envelopes; after an ungraceful owner death the client receives Resync { after }, resynchronizes, and acknowledges with ResyncDone (ADR 0023; M3.5). A member that opts into executor progress (ADR 0030) also yields Progress { job, jobId, attempt, seq, frame } messages, whose frame is decoded by that job’s progress schema. Over WebSocket they arrive as the server message t: "progress" with job, jobId, attempt, seq, and frame and no cursor or event, never inside a frame message; this amends ADR 0027, and clients ignore a t they don’t know. Progress is best-effort and display-only: it may be coalesced or dropped, a loss followed by a later frame of the same attempt shows as a seq gap, and it is never replayed after Resync or a reconnect. SSE event feeds carry no progress.
Reducers run optimistically: calling one applies its reduce to the client’s copy of committed state immediately, re-applies pending inputs over each committed state the server pushes, and drops the input when its receipt arrives, rolling it back if the receipt is a failure. Every handle exposes state as committed state plus pending inputs. Queries carry the handle’s last-seen commit version, so the nearest caught-up replica can answer with read-your-writes consistency. See ADR 0011.
Each command accepts a trailing { commandId, signal } options bag. The client mints a command ID when omitted and reuses it across delivery retries. Aborting or timing out only stops waiting; it does not roll back accepted work. Within the external retry horizon, an authorized retry with the same ID replays the durable receipt, while reusing it with different input fails with CommandConflict.
A client is never trusted: the server’s auth provider turns each request into a User or Anonymous caller, and the served actor’s access policy (with the runtime’s optional authorize) decides what it may do. An actor that declares neither answers 403 Unauthorized access_denied to every client call (ADR 0059); Actor.access.public opens one to anyone, for demos.
Stored outcomes require the original logical caller’s current access or explicit operator authority. Rotating credentials for that same caller does not change command identity. Revocation blocks new external calls and result reads without implicitly canceling accepted work.
Expired command identities are rejected even after receipt cleanup. Automatic retries must preserve the original identity and any required expiry metadata; the client must not replace an expired id to keep retrying. A fresh id is an explicit new operation, and expiry does not prove the earlier operation failed. The client mints v1 ids against the server’s database clock, which it learns from GET /protocol and every response’s durable-now, and sends them as Idempotency-Key; client.commandId() mints one ahead of a call. It retries retryable reasons and transport failures with the same id, honoring retryAfter, until timeoutInMs, signal, or the id’s expiry, and never mints a replacement id on its own: not after CommandExpired, and not after InvalidCommandId. An id rejected as future is retried unchanged once the server clock passes it. After window or version, the error says whether every attempt was answered with a proof of non-admission, and only then may the caller choose to send the operation again under a new id. Headers may be a function called per attempt, so a refreshed credential keeps the id. Queries send the greatest durable-version the client has seen as durable-min-version. See ADR 0027 and ADR 0004.
Declared application errors are thrown as their schema-defined classes. Framework failures use ActorError with a typed reason, isRetryable, and retryAfter. InvalidInput and TransportError belong only to the HTTP/Promise boundary, not typed in-process Effect handles. Each Effect method narrows its framework failures through ActorError.Of<Reasons> rather than adding every possible reason.
Implemented subset (M3.4)
Commands, queries, and optimistic reducers over HTTP are implemented, and so are event feeds, streams, and connections (M3.5). No server pushes committed state yet: a handle learns committed state from each non-batched reducer’s reply and fromhandle.state.reconcile(committed), such as with a state a query read.
-
Event feeds.
handle.events(Event, { after?, signal? })is anAsyncIterableof{ cursor, event, commandId, timestamp }(timestampin epoch milliseconds) over the actor’s SSE feed, for events the actor type lists infeeds. It is read overfetch, so it sendsheaderslike a command. When the response drops, goes 45 seconds without a byte (the server sends a keepalive every 15), or ends with a retryable reason, the feed reopens withLast-Event-IDset to the last cursor it delivered, afterretryAfter(from the envelope or theRetry-Afterheader, never sooner) or a jittered backoff. A feed ended byUnauthorized expiredreopens once with fresh headers. It throwsRetentionGapwhen events after the cursor were pruned,UnknownCursorfor a cursor the actor never issued, andActorErrorfor any other failure, includingNotCreatedfor an actor no command has created yet.breakorsignalcloses it. -
Watches.
handle.Query.watch(input?, { signal? }), on a query declaredwatch: true, is anAsyncIterableof the query’s decoded outputs: the current result, then the newest result after each change. It is state, not history, so it skips intermediate results and never repeats an unchanged one. A dropped connection is reopened after a jittered backoff with the greatest version any result carried asdurable-min-version(or the client’s read-your-writes token before the first result), so its first result is never older than one already delivered. It throws the query’s declared error as its class or theActorErrorthat ended it when a retry cannot help (Unauthorized,NotCreated,InvalidInputnot_watchable); a retryable end or an expired credential is retried once.signalends the iteration. -
Streams.
handle.Member(input?, { signal? })subscribes once and is anAsyncIterableof the member’s decoded outputs. It ends when the stream ends by itself. It throws the member’s declared error as its class, or theActorErrorthat ended it (SessionEndedActivationEnded,SlowConsumer, orUnauthorized). A response that closes withoutendthrows a retryableTransportErrornetwork. Streams have no cursor, so the client never resubscribes by itself. -
Connections.
handle.Member.connect(params, { signal?, onResync? })opens a WebSocket (ws:orwss:frombaseUrl, resolved against the page when relative) and sends theauthorizationheader fromheadersinhello, because browsers can’t set headers on a socket. It resolves once the server sentopen, with{ connectionId, cursor, messages, frames, send, close }. It rejects with the member’s declaredopenfailure as its class, or with anActorError.messagesyields, in order:Frame { frame, cursor, event }. Frames whoseeventthe client already delivered are dropped after a resync.Resync { after, reason, deadline }.ResyncReplayed.Progress { job, jobId, attempt, seq, frame }, for a member withprogress: { jobs }.frameis decoded by the job’sprogressschema and typed by it: the message type is a union over the jobs the member lists, so narrowing onjobtypesframe, as inif (message.job === "Render") message.frame.percent.ProgressUpdate,ProgressMessage, andProgressOfConnection<Member>from@rikalabs/akter/clientname these types; a member that lists no jobs has noProgressmessage, and code written against no particular member seesjob: stringandframe: unknown. Only connections carry progress to a client, so the types come per job, not per command. A progress message whose job the member doesn’t list, or whose frame doesn’t decode, is dropped, because progress is lossy anyway.
tthe client doesn’t know is ignored; one that isn’t valid JSON, or a knowntwith the wrong shape, ends the connection withTransportErrordecode.framesyields only the decoded member frames, without resync notices or progress. Iteration ends on a normal close and throws the session’sActorErrorotherwise. A socket that drops withoutendisSessionEndedHolderLostwithresync: true, and the caller reconnects: a connection is not reopened automatically, because a new one has a fresh session. AfterResync, the client callsonResync({ after })and acknowledges withresyncDoneonce it settles, and again afterresyncReplayed, because the holder ignores an acknowledgment that comes before the member’s own replay. Onreauthenticate, the client callsheadersagain and sends itsauthorization.
rooms.get(id) returns the same handle for the same id while it has pending inputs or listeners. handle.state is { current, pending, subscribe, reconcile }:
- Calling a reducer applies its
reduceto a copy ofcurrent’s committed state at once and appends its input topending. Until committed state is known,currentisundefined. - A handle sends its reducer calls one at a time in call order, each with its own command id and the usual retries, so each non-commutative reply is the committed state before every later pending input. A call’s
timeoutInMsandsignalinclude its wait behind earlier calls; one stopped while waiting is never sent.pendingreturns copies of its inputs. - A success receipt removes the input. A non-batched reducer’s reply replaces committed state; a batched reducer replies nothing, so its
reduceis applied to committed state. - A failure (a declared error, or any
ActorError, includingTimeout) removes the input and rethrows;currentbecomes committed state with the remaining inputs. A timed-out call may still commit; the next reply orreconcileshows it. - After every change,
currentis recomputed from committed state andpendingin order, and eachsubscribelistener is called with it. An input whosereducefails, throws, or returns a state the schema rejects is skipped incurrent; the server decides its receipt. reconcilereplaces committed state and reappliespending, so a state read before a pending input committed can show that input twice until its receipt arrives.
state is a reserved member tag, like ref.
room/contract.ts
X.client returns get(id) for keyed actors, get() for singletons, and get(id) plus create() for minted ones, where create() mints a UUIDv7 locally. Each handle method takes its input (omitted when the member has none) and { signal, timeoutInMs }, plus commandId for commands. Routes, key encoding, input and output codecs, and the declared-error decoder come from the definition. The entry imports no runtime, SQL, Cluster, Bun, or Node module; a test walks its import graph and builds it for the browser.
A headers provider that throws or rejects fails the call with its own error, unchanged and not retried. Retries of an id stop a second before it expires, or a quarter of its window before when the window is shorter than four seconds; until the client holds a database-clock sample from the last minute, that deadline is not applied.
A void member resolves undefined from its 204, and an output the server wrote as null for undefined decodes back to undefined. Clients of one baseUrl share its clock samples, retry window, and durable-version token; the state of the 64 most recently used base URLs is kept, and a client keeps the state it was created with. Actors.serve issues durable-version on every committed or replayed command, so a query after a client’s own command reads it even from a lagging replica (ADR 0052). The clock uses the lowest-latency sample of the last minute (at most 16 are kept) and ignores round trips over 5 seconds and every 504; with no sample from the last minute, the client reads /protocol again before minting, takes the id from /command-ids when that read leaves no sample either, and a window refusal makes it re-read the retry window. A minted id is issued at least a second, or one round trip, behind the estimated database clock, capped at a quarter of the retry window but never below half the round trip. Retries stop a second before the id expires. A retry-after header gives delay seconds or an HTTP date; a date is measured from the response’s date less the time since the request was sent, or from the local clock without one. Without a retryAfter, the delay backs off from 100 ms to at most 2 seconds.
A call rejects with:
- the declared error’s class, for a declared failure, replayed identically on retry;
ActorErrorwith the served reason (CommandExpired,CommandConflict,InvalidCommandId,Unauthorized,NotCreated,MailboxFull,RunnerAtCapacity,ActorUnavailable,Timeout,InvalidInput) and itsisRetryableandretryAfter;ActorErrorwithTimeoutwhentimeoutInMsorsignalstops the wait. A command’sTimeoutcarries its command id: the outcome is unknown and a retry with that id is safe. A query’s, or a command’s stopped before any id existed, has nocommandId;ActorErrorwithTransportErrorfor a response the server didn’t describe:networkfor a failed fetch,statusfor a status without an envelope (retried for 408, 429, and 5xx),decodefor a success body thesuccessschema rejects, anddefectfor the server’s opaque 500, which carries nothing but its status.
InvalidCommandId from the client carries neverAdmitted: true only when the client minted the id itself, every attempt with it was answered with that refusal, and no other call with the id is still waiting, so no turn can have run under it. Only then is resending under a new id a retry rather than a second operation.
Effect callers can catch the wrapper with Effect.catchTag("ActorError") or branch with Effect.catchReasons. On Effect 4.0.0, omitting orElse retains the full ActorError in E; branching is not automatically exhaustive error-channel elimination. See the error contract. OpenAPI is the intended input for external client and tool generators.
React (CR.6)
@akter/react wraps the Promise client in hooks. Every hook talks to the server only from effects and event handlers, so rendering on a server does no I/O: state hooks return undefined and feeds and connections start empty.
useActor(client, id)returnsclient.get(id), stable whileclientandidare.useCommand(client, (input, options) => handle.Member(input, options))holds one user intent.run(input)mints a command id withclient.commandId()before sending and passes it inoptions.retry()sends the same input under the same id, so a retry after a lost response or a timeout replays the receipt instead of running the command twice.stateisidle,pending,successwithdata, orerrorwith the failure andexpired.expiredis true forCommandExpired:retrycannot help, and a newrunis a new operation.reset()forgets the intent.useQuery(query, deps)runsquery({ signal })on mount and whendepschange. Only the latest read updates{ data, error, loading };refetch()reads again.useWatch((options) => fleet.View.subscribe(filter, options), deps)follows a served fleet view the same way, wherefleetisfleetClient([View], options)(fleet views).useWatch((options) => handle.Query.watch(input, options), deps)follows a watched query. It returns the newest result asdataand theerrorthat ended the watch; an actor no command has created yet (NotCreated) is asked for again every 500 ms, asuseEventFeeddoes. The Promise client itself treatsNotCreatedas final; waiting for creation is the hooks’ choice, and unmounting stops the wait at once.depsis named by the caller, as foruseEffect.useEventFeed(handle, Event, { after?, storageKey? })followshandle.events. It returns theentriesdelivered since mount, the lastcursor, theerrorthat ended the feed, andgapwhen that error isRetentionGap, which is never skipped. WithstorageKey, each delivered cursor is written tosessionStorage, and a remount or reload resumes after it. A feed of an actor no command has created yet (NotCreated) is asked for again every 500 ms.useConnection(handle.Member, params, { key?, onResync?, keep? })holds one connection while mounted with the same member and session key. Primitive params (or none) are their own key; object params requirekey, a string, number, or boolean, and do not compile without one. A new key closes the connection and opens one with that render’s params; params that change under the same key are not sent. It returnsstatus(connecting,open, orclosed), the latestkeepframes (default 100), the latestkeepexecutorprogressmessages (default 100; the Promise client’sProgressmessages, typed by job as above, and display-only, so aseqgap within onejobIdandattemptis a dropped one), theerrorthat ended it, andsend. A closed connection is not reopened by itself, because a new one is a new session.useActorState(handle)is the handle’sstate: committed state with pending optimistic reducer inputs applied, throughuseSyncExternalStore.
/react/rooms/<id> page uses every hook under StrictMode.
Offline queue (M6.5)
X.client({ baseUrl, offline: Offline.indexedDb("chat") }) saves every command before its first attempt and delivers it under the id it was saved with, across outages, reloads, and lost replies (ADR 0058). Offline.indexedDb(name) keeps one record per command in the IndexedDB database akter:<name>; Offline.memory() keeps them in memory. Any object with entries(), save(command), and remove(commandId) is an OfflineStore. Each command is saved under the client’s identity, a stable key for the signed-in user (never a credential); without one, the key is the iss and sub of an authorization: Bearer JWT, and an offline client with neither refuses to queue. Only the current principal’s commands are sent: another’s show as held until that user signs back in or the application discards them, so a shared device never sends one user’s commands as another.
- Calls stay Promises. A command resolves with its output once the server answers.
timeoutInMsorsignalstops the wait withTimeoutcarrying the command id, as always, but the command stays queued and is still delivered. A call aborted before its command was saved queues nothing. - Order. Commands of one actor are sent one at a time in call order; different actors are independent. A command that expired or was rejected for good does not hold back later ones. Tabs sharing a store deliver what they read, and a duplicate is a retry the receipt answers.
- Retries. Retryable failures wait as online retries do, until the id’s retry deadline; the browser’s
onlineevent andqueue.flush()skip the wait. A rejected credential stops that actor’s queue, keeping its commands, untilflush()or another command for the actor. - Expiry. A command whose id passed its retry window is never sent and is never given a new id. Its caller is rejected with
CommandExpired, and it stays inpendingasexpireduntildiscard. A declared or other final failure stays asfailedwith the server’s answer, decoded again after a reload. A repeated id with the same input joins the queued command; with other input it failsCommandConflict. - Failures of the store. A command that could not be saved rejects with
OfflineStoreError(operationsave) and was never sent. An unreadable store rejectsqueue.readyand every later call withOfflineStoreError. A failed removal after a commit is reported throughreportErrorand the receipt answers the replay. On Node, which has no browserreportError, the client reports through Effect’s error logger instead; a listener’s failure never interrupts notification of the other listeners. - Minting. With a store, a mint that cannot reach the server within three seconds uses the retry window and clock offset this page last learned; a page that never reached the server rejects with the network error.
- React.
useCommandneeds nothing more:runmints its id throughclient.commandId(), which works offline, the queued command keeps that id, andretryjoins it or replays its receipt. Show the queue withuseSyncExternalStoreoverclient.offline.subscribeandpending, as the chat example’s React page does with?offline=1. - Reducers. An optimistic reducer stays applied while its command waits and follows the command’s delivery, not the call’s timeout. After a reload
handle.stateshows committed state until the queue’s commands land.