Skip to content

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.5.0                                        # 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>

See Event sourced 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)
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.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)

See Views.

Consumer

Part API
Base class Consumer<M, Out>
Statics componentId, source or topic, message; to publish, producesTo and out
Must define onMessage(message): ConsumerEffect<Out>
May override onDelete(), which ignores by default
In a handler this.subject, this.metadata, this.client, this.effects
Effects produce(out, metadata?), done(), ignore()

Delivery is at least once. A consumer that produces needs ANKKA_KAFKA_BOOTSTRAP_SERVERS on the sidecar. See Consumers.

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), 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.cancel(timerId)

Scheduling twice under one id replaces the earlier timer. See Timers.

Agent

Part API
Base class Agent
Statics componentId, handlers, optionally role, maxToolCallSteps, tools, guardrails
Declarations command(...) and stream(name, input?, run) in handlers; tool(name, description, Input, run) in tools; guardrail(name, check) in guardrails
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. See Agents.

HTTP endpoint

Part API
Base class Endpoint
Statics prefix, acl (required: Acl.allowAll, Acl.denyAll or Acl.authenticated), routes
Declarations get(template, reply, run, options?), post/put/patch/del(template, body?, reply, run, options?), sse(template, run, options?); options may carry acl for that route alone and params schemas narrowing path parameters
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
In a handler this.request: params, query.get/getAll, headers.get, principal, metadata; 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)
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.

Running a service

import { Ankka } from "ankka"
await Ankka.service().register(ShoppingCartEntity).register(ShoppingCartEndpoint).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) ankka/testkit A consumer's onMessage and onDelete, and what it produced.
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.