TypeScript SDK¶
The TypeScript SDK is the npm package ankka, for Node.js 22.22 and later. A TypeScript service is a
process that the ankka sidecar hosts: the sidecar owns the journal, sharding, projections, timers, HTTP and
the agent loop, and your classes decide what each command does. This page lists what each component kind
is made of. Services in other languages explains the model.
Installing¶
The SDK is on npm as ankka. Every ankka release publishes the SDK
at the same version, so pin the version of the platform you deploy to:
npm install ankka@0.16.1 # the SDK
npm install -D testcontainers @testcontainers/postgresql # only for the integration testkit
The package is an ES module and depends on @connectrpc/connect, @connectrpc/connect-node and
@bufbuild/protobuf, nothing else. The integration testkit's Docker dependencies are optional peers, so a
service that installs the SDK gets no Docker tooling. Docker is needed for integration tests and for running
the sidecar locally; a JVM is not. To work against an unreleased SDK, npm install /path/to/ankka/sdks/typescript
after building it as described under Developing the SDK.
A service runs from source: Node strips the types, so node main.ts is the whole build. That rules out the
TypeScript syntax Node cannot run — enum, parameter properties, decorators — and the SDK uses none of
it. Set erasableSyntaxOnly in tsconfig.json and the compiler refuses it too.
Shapes and codecs¶
Every value that crosses the protocol is declared once as a shape, and two things are derived from it: the TypeScript type and the codec.
import { s, type Infer, jsonCodec } from "ankka"
const LineItem = s.record("LineItem", { productId: s.string, name: s.string, quantity: s.int })
type LineItem = Infer<typeof LineItem> // { productId: string; name: string; quantity: number }
const Event = s.sumType("ShoppingCartEvent", { ItemAdded: { item: LineItem }, CheckedOut: {} })
type Event = Infer<typeof Event> // { type: "ItemAdded"; item: LineItem } | { type: "CheckedOut" }
const codec = jsonCodec(Event, "shopping-cart-event") // the manifest the journal stores it under
| Shape | TypeScript type | On the wire |
|---|---|---|
s.string |
string |
a JSON string; at top level, text/plain |
s.int |
number, whole, within ±2⁵³ |
a JSON integer; at top level, text/plain |
s.long |
bigint |
a JSON integer, lossless past 2⁵³ |
s.double |
number |
a JSON number as Scala writes it: 1.0, 1.0E10 |
s.boolean |
boolean |
true / false |
s.instant |
Instant |
ISO-8601 in UTC with 0, 3, 6 or 9 fractional digits |
s.duration |
Duration |
ISO-8601, PT1.5S |
s.localDate, s.localDateTime |
LocalDate, LocalDateTime |
ISO-8601 strings |
s.bytes |
Uint8Array |
base64 inside JSON; raw bytes at top level |
s.option(x) |
T \| null |
null when absent; an absent field reads as null |
s.list(x) |
T[] |
a JSON array |
s.stringMap(x) |
Record<string, T> |
a JSON object |
s.record(name, fields) |
an object type | a JSON object, every field written, in declaration order |
s.sumType(name, cases) |
a discriminated union on type |
the case's object with "type": "<Case>" |
s.enumeration(name, ...values) |
a string literal union | {"type": "Ready"} |
s.lazy(() => X) |
T |
inline, for recursive shapes |
Done / done |
the one value done |
the empty done payload |
The encoding is exactly what the Scala and Python SDKs write, so one journal is readable from all three;
Serialization has the rules. Field names are the wire contract: use productId
rather than product_id when a Scala service shares the data. A codec of your own is any object with
manifest, contentType, encode and decode; pass it wherever a shape is accepted.
The shape every component shares¶
A component is a class that extends the kind's base class and declares, as statics, its componentId, its
codecs, and a table of handlers whose first argument is the wire name:
export class ShoppingCartEntity extends EventSourcedEntity<ShoppingCart, ShoppingCartEvent> {
static readonly componentId = "shopping-cart"
static readonly state = jsonCodec(ShoppingCart, "shopping-cart")
static readonly events = jsonCodec(ShoppingCartEvent, "shopping-cart-event")
static readonly handlers = {
addItem: command("add-item", LineItem, Done, (cart: ShoppingCartEntity, item) => cart.addItem(item)),
getCart: query("get-cart", ShoppingCart, (cart: ShoppingCartEntity) => cart.effects.reply(cart.state)),
}
...
}
command(name, input, reply, run) and query(name, reply, run) take shapes for the input (omitted when
there is none) and the reply, and type run from them; query accepts only a function returning a
read-only effect, so a query that persists does not compile. The property name (addItem) is yours; the
wire name ("add-item") is the platform's. register refuses a class missing a static its kind requires,
at compile time; validate reports every remaining problem at once.
Event sourced entity¶
| Part | API |
|---|---|
| Base class | EventSourcedEntity<S, E> |
| Statics | componentId, state, events, handlers, optionally snapshotEvery |
| Must define | emptyState(): S, applyEvent(state, event): S |
| Declarations | command(name, input?, reply, run), query(name, input?, reply, run) |
| In a handler | this.state, this.entityId, this.context, this.effects, this.client |
| Effects | persist(e, ...more), persistAll(events), deleteEntity(), expireAfter(d), then .thenReply(s => r), .thenReplyState(), .thenNoReply(), .deleteEntity(), .expireAfter(Duration); reply(r), error(msg, code), noReply() |
| Types | a command returns EventSourcedEffect<S, E, R>; a query returns ReadOnlyEffect<S, E, R> |
| Replication | static readonly replicated = true; effects handOverTo(installation), replicateTo(installation), stopReplicatingTo(installation), alone or after persist; this.context.installation, this.context.isHome; applyEvent(state, event, context?), whose EventContext has installation, isLocal, sequence; EventSourcedTestKit.of(cls, id, client, { kind: "follower", home, installation }) and kit.replay(events, writtenAt) |
See Event sourced entities and Replicated entities.
Key value entity¶
| Part | API |
|---|---|
| Base class | KeyValueEntity<S> |
| Statics | componentId, state, handlers |
| Must define | emptyState(): S |
| In a handler | this.state, this.entityId, this.context, this.effects, this.client |
| Effects | updateState(s), deleteEntity(), then .thenReply(s => r), .thenReplyState(), .thenNoReply(), .expireAfter(Duration); reply(r), error(msg, code), noReply() |
See Key value entities.
View¶
| Part | API |
|---|---|
| Base class | View<E, Row> |
| Statics | componentId, source (a component class) or topic, events, row, optionally queries (["get", "all"] by default), declared (declaredQuery(name, statement)) and version |
| Must define | onChange(event): ViewEffect<Row> |
| May override | onDelete(): ViewEffect<Row>, which deletes the row by default |
| In a handler | this.row (the current row or null), this.subject, this.metadata, this.standing (a workflow source's Standing, else undefined), this.effects |
| Effects | updateRow(row), deleteRow(), ignore() |
| Querying | client.views.get(viewId, key, Row), client.views.all(viewId, Row), client.views.query(viewId, name, key, Row), client.views.ask(viewId, name, values, Row, limit?) |
| Table name | tableOf(componentId), for a statement to name |
Keyed view¶
| Part | API |
|---|---|
| Base class | KeyedView<Row> |
| Statics | componentId, row, sources (on(Entity, Events, handler, { deleted? }) each), optionally declared and version |
| In a handler | this.subject, this.metadata, this.rows.get(key), this.rows.ask(name, values), this.effects |
| Effects | updateRow(key, row), deleteRow(key), updateRows(...), deleteRows(keys), ignore(), all(...) to combine |
| Testing | KeyedViewTestKit.of(View) |
See Views.
Consumer¶
| Part | API |
|---|---|
| Base class | Consumer<M, Out> |
| Statics | componentId, source or topic, message; for a topic, startFrom, contract, broker, parallel; to publish, producesTo (a topic, or a Publication with its contract and broker) and out |
| Must define | onMessage(message): ConsumerEffect<Out> |
| May override | onDelete(), which ignores by default |
| In a handler | this.subject, this.sequenceNumber, this.metadata, this.standing (a workflow source's Standing, else undefined), this.installation, this.localOrigin, this.client, this.effects |
| Replicated sources | static readonly mirroredChanges = true gives a consumer that publishes changes written at other installations too |
| Effects | produce(out, metadata?), produceAll(messages), done(), ignore() |
| One of several messages | { payload, key?, metadata? }; key is a record key other than the message's subject |
Delivery is at least once. A consumer that produces needs ANKKA_KAFKA_BOOTSTRAP_SERVERS on the sidecar.
produceAll publishes its messages in order, and the change is handled when the broker has accepted all
of them; an empty list is handled at once. See Consumers.
Graph consumer¶
A consumer that publishes its source as graph deltas. It is registered as a consumer and can publish nothing but deltas.
| Part | API |
|---|---|
| Base class | GraphConsumer<M> |
| Statics | componentId, source or topic, message, producesTo; contract, broker, parallel for a topic; no out |
| Must define | onMessage(message): GraphEffect, which may be async |
| May override | onDelete(), which ignores by default |
| In a handler | this.subject, this.sequenceNumber, this.metadata, this.client, this.graph, this.effects |
| Elements | this.graph.node(id, { labels?, properties?, version? }), edge(id, { type, from, to, properties?, version? }), tombstoneNode(id, { version? }), tombstoneEdge(id, { type, from, to, version? }) |
| Effects | this.effects.publish(elements), done(), ignore() |
| A refusal | GraphError, whose why names the rule broken |
| Export | What it is |
|---|---|
Element, Delta |
An element as described, and an element at a version: what is published and read |
readDelta(value, key?) |
Reads a record's value into a Delta; given a key, also checks it is the delta's element key. Throws, naming the fault. |
graphDeltaCodec |
The delta codec, under the manifest ankka.graph-delta.v1: a message shape for a consumer that reads a delta topic |
nodeKey(id), edgeKey(id), elementKey(element), GRAPH_DELTA_SCHEMA |
The element keys and the contract's name |
A property value is a string, a boolean, a number, a bigint, or an array of one of those. An
integral number must be a safe integer and is published as an integer; larger integers are bigint,
and the reader returns an integer past 2⁵³ as one. A version is a safe-integer number or a bigint. See
Publish a graph.
Workflow¶
| Part | API |
|---|---|
| Base class | Workflow<S> |
| Statics | componentId, state, handlers, steps, optionally settings |
| Must define | emptyState(): S |
| Declarations | command, query in handlers; step(name, input?, run) in steps |
| In a handler or step | this.state, this.entityId, this.context, this.client, this.effects, this.stepEffects |
| Command effects | updateState(s), transitionTo(step, input?), then .thenTransitionTo(step, input?), .thenReply(s => r), .thenReplyState(), .thenNoReply(); reply(r), error(msg, code) |
| Step effects | updateState(s) then, or directly: transitionTo(step, input?), pause({ after, onTimeout, onTimeoutInput }), end(), fail(msg, code) |
| Settings | workflowSettings({ timeout, defaultStepTimeout, defaultRecovery, steps: { name: { timeout, recovery } } }) |
| Recovery | { maxRetries: n, failoverTo: "step" } |
Steps may be async and may call other components. A step that throws is retried as its recovery says,
then failed over; a step that returns fail(...) ends the workflow. A transition names a step by wire
name, and its input is encoded with that step's declared shape. See Workflows.
Timed action and timers¶
| Part | API |
|---|---|
| Base class | TimedAction |
| Statics | componentId, actions |
| Declarations | action(name, input?, run) |
| In a handler | this.metadata (ankka.timer, ankka.attempts, ankka.due), this.dueTime (a Date), this.client, this.effects |
| Effects | done(), fail(msg, code) |
| Scheduling | await client.timers.schedule(timerId, Duration, { component: Cls, handler: Cls.actions.name }, input), or by name { kind: "timed-action", componentId, name, input: Shape }; await client.timers.scheduleRecurring(timerId, delay, period, { component: Cls, handler: Cls.actions.name }, input), or by name { componentId, name, input: Shape }; await client.timers.cancel(timerId) |
Scheduling twice under one id replaces the earlier timer, except that a recurring timer set again for the same handler with the same period keeps its next due time. See Timers.
Agent¶
| Part | API |
|---|---|
| Base class | Agent |
| Statics | componentId, handlers, optionally role, maxToolCallSteps, tools, guardrails, mcpServers, resultGuardrails |
| Declarations | command(...) and stream(name, input?, run) in handlers; tool(name, description, Input, run, { approval }) in tools; guardrail(name, check) in guardrails; mcpServer(name, { url, service, project, path, headers, approval }) in mcpServers; resultGuardrail(name, (tool, text) => …) in resultGuardrails |
| Approval | { approval: true }, or { approval: { withinMs } } for a time limit |
| Calling | .ask(input) → { kind: "answered", value } or { kind: "awaiting-approval", requests }; .decide(approvalId, { approved, by, note }), answered as ask; .streamParts(input); .invoke and .stream reject with ApprovalAwaited |
| In a handler | this.sessionId, this.metadata, this.client, this.effects |
| Effects | systemMessage(t), userMessage(t), model(name), then .withModel(name), .withContext(t), .memory(bool), .tools(...names), .guardrails(...names), .thenReply(), .thenReplyJson<R>(); error(msg, code) |
A handler returns a plan; the sidecar runs the model loop, calls tools back in the process with the model's
arguments decoded by the tool's input shape, and checks guardrails. A tool's input shape is also the JSON
Schema the model sees. A guardrail's check(stage, text) returns null to pass or a reason to block. The
sidecar connects to the MCP servers and enforces approval, so the process never runs a server's tool nor a
tool that awaits a decision. The unit kit takes scripted servers, AgentTestKit.of(Agent, sessionId, model,
client, { mcp }); ask answers text and rejects with ApprovalAwaited when the turn waits, outcome
answers the outcome, and decide goes on. See Agents and
MCP servers.
Autonomous agent¶
| Part | API |
|---|---|
| Task type | taskType(name, description, { result: Schema, rules: [taskRule(name, check)] }); a check returns accepted() or rejected(reason) |
| Base class | AutonomousAgent |
| Statics | componentId, description, accepts: [taskAcceptance(type, { maxIterations })], optionally instructions, tools, guardrails, model, settings, mcpServers, resultGuardrails |
| In a tool | this.client, this.taskId |
| Calling | client.tasks.create(type, instructions, { id, attachments, dependsOn }); client.forTask(id).get(type), .wait(type), .cancel(); client.forAutonomousAgent(Agent, instanceId).assign(...), .suspend(), .resume(), .terminate(), .state() (with awaiting), .notifications(), .decide(approvalId, { approved, by, note }); client.forAutonomousAgent(Agent).runSingleTask(type, instructions) |
The TypeScript SDK declares autonomous agents and calls them. Its unit testkit does not yet script one:
test an autonomous agent's behaviour end to end, through a sidecar with ANKKA_MODEL_SCRIPT. See
Autonomous agents.
HTTP endpoint¶
| Part | API |
|---|---|
| Named events | an sse handler may yield sseEvent(name, value) among its text, sent as an event of that name with the value as JSON (protocol 1.11) |
| Base class | Endpoint |
| Statics | prefix, acl (required: Acl.allowAll, Acl.denyAll, Acl.authenticated or Acl.allowCallers(...)), routes |
| Declarations | get(template, reply, run, options?), post/put/patch/del(template, body?, reply, run, options?), sse(template, run, options?), socket(template, (self, req, socket) => Promise<void>, options?); options may carry acl for that route alone and params schemas narrowing path parameters |
| A socket | for await (const text of socket), await socket.receive() (undefined once closed), await socket.send(text), which rejects with SocketClosed once closed; testing, EndpointTestKit.socket(path, frames) answers what the handler sent and how it ended |
| Handlers | (self, req, body) => reply, sync or async; req.params is typed from the template; the return value is encoded with the reply shape, done or undefined answers 204 |
| Callers | Callers.internet, Callers.service(name, { project }), Callers.anyInProject, Callers.self, Callers.granted; a request's caller.kind is gateway, service, machine or local |
| In a handler | this.request: params, query.get/getAll, headers.get, principal (subject, name, email, emailVerified, roles, claims, issuer), metadata, caller ({ kind: "gateway" }, { kind: "service", project, name } or { kind: "local" }); this.client, scoped to the request |
| Errors | throw new HttpProblem(status, message); a CommandError from a call answers with its code's status |
The process never binds an HTTP port: the sidecar serves the routes and forwards each request. acl is
required, as it is in the Scala and Python SDKs: a class without one is refused where it is registered, by
the type checker, rather than serving requests under a posture nobody chose. A route's acl option replaces
the endpoint's for that route. See HTTP endpoints.
Calling components¶
// typed, from the handler table
await this.client.of(ShoppingCartEntity, cartId).call(ShoppingCartEntity.handlers.addItem).invoke(item)
// by name, for a component whose class is not at hand
await this.client.forEventSourcedEntity("shopping-cart", cartId).call("add-item", LineItem, Done).invoke(item)
for await (const token of this.client.forAgent("assistant", session).call("chat", s.string).stream(question)) ...
| Target | Call |
|---|---|
| Any component, typed | client.of(Cls, entityId).call(Cls.handlers.name) |
| Event sourced entity | client.forEventSourcedEntity(componentId, entityId) |
| Key value entity | client.forKeyValueEntity(componentId, entityId) |
| Workflow | client.forWorkflow(componentId, workflowId); client.of(Cls, id).awaitEnd(timeoutMillis) waits for its end, .awaitEndParts(timeoutMillis) as parts for an sse route (awaitPartJson renders one), and .call(Cls.handlers.name).thenAwaitEnd(timeoutMillis).invoke(input) starts and waits as one call |
| Agent | client.forAgent(componentId, sessionId) |
.call(...) gives an invocation: await invocation.invoke(input), or for await (const token of
invocation.stream(input)) for a streaming agent handler. A refusal rejects with CommandError, whose
code is the refusal's. Inside a handler this.client already carries the request's trace, so the call is
a child span; client.withMetadata(md) scopes a client by hand. See
Calling components.
Secret store¶
The service's secret store, for a value it must keep and never record, such as a credential a person
gave it (Secrets a service keeps). The consumer, graph consumer, timed action,
agent, autonomous agent and endpoint classes have secrets, and a workflow has it in a step; the two
entity classes and View have no such property, so reaching for one is a type error. await
this.secrets.put(name, value), await this.secrets.get(name) (the value, or undefined) and await
this.secrets.delete(name). A refusal is a CommandError with the runtime's code.
The runtime beside the process holds the store and its key; the process never sees the key. For a unit
test, assign component.secrets = new InMemorySecrets(); noSecrets() is a store whose every call
throws. The integration test kit puts a generated ANKKA_SECRET_KEY on the runtime it starts. Needs
protocol 1.4: an earlier runtime is reported as too old for the store.
Other services¶
The consumer, graph consumer, timed action, agent, autonomous agent and endpoint classes call another
service as this one through this.services, and a workflow in a step; the two entity classes and View
have no such property. this.services.service("orders") or this.services.service("billing",
"invoices") answers a client with get(path, returns), getText(path), post(path, body, { body,
returns }), put(…), delete(path) and request(method, path, { body, contentType, headers }). No
answer is ServiceUnresolvable, ServiceIdentityMismatch or ServiceUnanswered; an answer outside 2xx to
a typed helper is ServiceCallFailed; all four extend ServiceError. For a unit test, assign
component.services = new ScriptedServices(); noServices() throws on every call. Needs protocol 1.8:
an earlier runtime is reported as too old to call another service. See
Calling other services.
Running a service¶
import { Ankka } from "ankka"
const service = Ankka.service()
.register(ShoppingCartEntity)
.register(ShoppingCartEndpoint)
await service.listen()
listen() serves the protocol on port 9010, or ANKKA_PROCESS_PORT, on loopback, and waits for the
sidecar to connect. Locally, run the sidecar with docker compose --profile polyglot up -d from the ankka
repository. Ankka.service().spec() returns what discovery will say without listening.
Testing¶
| Kit | Import | Drives |
|---|---|---|
EventSourcedTestKit.of(Cls, id) |
ankka/testkit |
One entity; await call(Cls.handlers.x, input) returns events, state, retention and reply. |
KeyValueTestKit.of(Cls, id) |
ankka/testkit |
One key value entity. |
WorkflowTestKit.of(Cls, id) |
ankka/testkit |
One workflow: call, runStep, runUntilEnd (stops at a pause), resume, progress. |
ViewTestKit.of(Cls) |
ankka/testkit |
A view's onChange(key, event) and onDelete(key), and the rows. |
ConsumerTestKit.of(Cls, client?) |
ankka/testkit |
A consumer's onMessage(message, subject, metadata) and onDelete, and what it produced: each message's payload, metadata and the record key it named. |
GraphConsumerTestKit.of(Cls, client?) |
ankka/testkit |
A graph consumer's onMessage(message, { subject, sequence }) and onDelete({ subject, sequence }), each returning the Deltas published, read back from their bytes. |
TimedActionTestKit.of(Cls) |
ankka/testkit |
A timed action's handlers. |
AgentTestKit.of(Cls, sessionId, new ScriptedModel()) |
ankka/testkit |
An agent's plan, tools and guardrails, against a scripted model that fails when the script runs out. |
EndpointTestKit.of(Cls) |
ankka/testkit |
An endpoint's routes by path, with no sidecar. |
AnkkaTestKit.start(service) |
ankka/testkit |
The whole service through the real sidecar image and a throwaway Postgres. restart() starts a new sidecar on the same database. |
Unit testkits need nothing running and still round-trip every value through its codec. They depend on no
test runner: node --test and vitest both work. See Testing.
Developing the SDK¶
From sdks/typescript in the ankka repository:
npm ci # install, with the development dependencies
npm run proto # copy the protocol in and regenerate the stubs
npm run typecheck # tsc over src, test and examples
npm test # unit testkits, the encoding fixtures, the servicers
npm run test:slow # through the real sidecar and Postgres; needs Docker
npm run conformance # serve the reference service and run the platform's conformance suite
npm run example # the sample, beside `docker compose --profile polyglot up -d`
npm run build # emit dist/, what the package ships
The SDK carries a copy of the protocol so it can be built on its own; npm run proto refreshes it. The
conformance suite needs sbt and runs the platform's suite against the SDK's reference service. See
Adding a language SDK.