Adding a language SDK¶
ankka hosts a service in a language off the JVM through the sidecar: the ankka runtime runs beside the
service's process and speaks a gRPC protocol to it. The platform never learns which language is on the other
end. A new language therefore needs no change to the platform, only an SDK, and an SDK is compatible when it
passes two things: the encoding fixtures and the conformance suite. The Python SDK in
sdks/python, the TypeScript SDK in
sdks/typescript and the Go SDK in
sdks/go are the working examples; the Go
SDK, a compiled language with no code generation at build time, is described in
The Go SDK as an example.
There is a second route for a language that compiles to WebAssembly: a guest library, which builds a
service to a module the runtime loads into its own process instead of running a process beside it. It
answers the same discovery and the same messages, across the module's linear memory rather than over gRPC,
and passes the same fixtures and the same suite. The Rust crate in
sdks/rust is the working example; see
A guest library.
There is a third route for a language that runs on the JVM: an embedded SDK, a facade over the Scala SDK
that the runtime hosts in its own process, as it hosts a Scala service. There is no sidecar, no protocol and
no module: the facade reads the developer's classes when the service starts and hands the runtime the same
descriptors a Scala service registers. The Java SDK in
modules/sdk-java and the Kotlin
SDK over it in sdks/kotlin are the
working examples; see An embedded SDK.
What an SDK does¶
- Serves the protocol. It implements every service in the protocol except
Client, on loopback atANKKA_PROCESS_PORT(9010 by default), and calls the sidecar'sClientservice atANKKA_SIDECAR_ADDRESS(127.0.0.1:9011by default). The definitions and the rules they do not state are in Sidecar protocol. - Answers discovery. It describes every registered component and endpoint in a
Spec: kinds, component ids, handler wire names and whether each is read-only or streaming, each kind's details, and the protocol version it speaks. It logs whatever the sidecar reports throughReportError. - Gives developers the component model in their language's idiom. Components declared with a stable component id and handlers declared with wire names separate from method names; handlers that return effects as values rather than performing them; queries that can only return a read-only effect, refused at registration otherwise.
- Encodes values exactly as the Scala SDK does. Its default codec produces and accepts the encoding in
protocol/ENCODING.md, so a journal written in one language is readable in the other. - Ships testkits. Unit testkits that run one component with no sidecar and still round-trip every value through its codec, and an integration testkit that starts Postgres and the real sidecar image.
The protocol artifact¶
protocol/ is what an SDK consumes: the
.proto files under src/main/protobuf, ENCODING.md, and fixtures/. Copy the whole directory into the
SDK so it builds on its own, and generate stubs from the copy; the Python SDK's scripts/proto.py, the
TypeScript SDK's npm run proto and the Go SDK's scripts/proto.sh do both.
Continuous integration checks that the copy is identical to the original, so a protocol change reaches every
SDK deliberately.
The encoding fixtures¶
Each file in protocol/fixtures
is one encoded value: a manifest, a content type, the bytes in base64, and the value in a language-neutral
JSON form. The fixtures are generated from the Scala codecs, and a build check fails if regenerating them
would change a committed file, so they always describe what the Scala SDK actually writes.
The SDK's own test runner reads every fixture, decodes the bytes with the codec the manifest and content type
select, compares the result with the value, re-encodes it and compares the bytes. A fixture with no matching
codec is a failure, never a skip. The Python SDK's tests/test_encoding_fixtures.py, the TypeScript SDK's
test/encoding-fixtures.test.ts and the Go SDK's fixtures_test.go are the models.
The conformance suite¶
ConformanceSuite, in
sidecar/src/test,
is the platform's definition of a compatible SDK. It drives a reference service through that service's own HTTP
routes and reads the journal directly, so it works against any language. Each behaviour is one test case named
for what it checks, such as es.refusal-persists-nothing, so a failure names the behaviour.
It runs in two modes:
sbt 'sidecar/testOnly *ConformanceSuite' # the Scala reference, in-process
sbt 'sidecar/testOnly *ConformanceSuite' -Dankka.conformance.target=127.0.0.1:9010 # a process listening there
With a target, the suite starts the sidecar against a throwaway Postgres and points it at the process listening at that address. Without one, it runs the Scala reference service in-process, so both hosting modes are held to one definition. Docker is needed for Postgres.
The reference service¶
An SDK passes the suite with a reference service of its own: the same components, wire names and routes as
the Scala reference, ConformanceReference. They include a shopping cart entity, an entity whose handlers
exercise every effect, a key value entity, a workflow with a pause, a failure and a compensation, a view, a
consumer, a timed action, an agent with a tool and a guardrail, and three endpoints including one that
requires authentication. One route calls another service through the SDK's own client: the suite plays that
other service on loopback, records what it was sent, and reads back what the reference's handler got, so a
call to another service means the same thing in every language. The reference also declares an MCP endpoint
at /mcp, admitting client keys and tokens, with three tools: get_cart, which answers the cart beside the
principal's subject and claims; add_item, whose refusal is a tool error; and checkout, declared as a
reference to the cart's command. Two routes, POST /mcp-keys and DELETE /mcp-keys/{id}, issue and revoke a
client key through the SDK's own client keys, and GET /mcp-keys lists them. The mcp-endpoint.* cases drive
that endpoint with the raw protocol, so an SDK's MCP endpoint is held to the same answers as the Scala one. The Scala reference, ConformanceReference, beside the suite is the full list of
components, wire names and routes, and the suite itself names every behaviour. The behaviours are also
written down, one per case, in the
conformance contract.
The Python reference service is the SDK's shopping cart example plus a conformance entity and endpoint, in
examples/shopping_cart.
uv run conformance in sdks/python serves it and runs the suite against it, and exits with the suite's
status. The TypeScript reference is the same shape, in
examples/shopping-cart,
served by npm run conformance in sdks/typescript, and so is the Go reference, in
examples/shopping-cart,
served by go run ./cmd/conformance in that directory. ANKKA_CONFORMANCE_ONLY narrows any of these runs to
matching behaviours, as a glob over the full case name ('*es.*').
The Go SDK as an example¶
The Go SDK is a process beside the sidecar, as the Python and TypeScript SDKs are, in a language that is compiled, statically typed and fetched as source. The choices it makes are the ones a similar language faces:
- The generated stubs are committed.
go getfetches a module's source and runs no generator, so the module carries its stubs ininternal/pb, generated from its copy of the protocol byscripts/proto.shwith generators pinned in a nestedtoolsmodule.scripts/proto.sh --checkregenerates into a temporary directory and fails when the committed stubs differ, and continuous integration runs it beside the diff of the copy. - Components are values, built by generic constructors.
ankka.EventSourced[State, Event](id, ...)takes the component id, the event cases and the handlers, each declared with its wire name (ankka.Command("add-item", addItem)), andankka.NewService(...)takes every component and endpoint explicitly. Nothing is found by reflection over a package. - The read-only rule is a type. A handler declared with
ankka.Querymust return anankka.ReadOnlyEffect, so a query that persists does not compile, and an endpoint with no access control does not either. The SDK's tests prove both by type-checking files that must fail (testdata/compilefail), with a control file that must pass, so the check cannot succeed by failing to run. - A refusal is an error. A handler returns its effect and an
error;ankka.Refuse(code, message)is an error the runtime answers with that code, and any other error is a failure. - The codec is planned once, from the type. The default codec reads a type's fields by reflection when
the component is declared, names each field by its
jsontag or else by its name with the leading capitals lowered, and refuses what the encoding cannot describe — an embedded field, anany, a tag option, an interface with no registered cases — as a problem at start rather than a failure at the first message. Sum types are interfaces whose cases are registered withankka.Cases, each under its discriminator. - Every problem at once.
Service.Problemslists every problem with the declarations together, andStartrefuses with all of them, before the process answers discovery. - Slow tests are opt in. The unit test kits run in
go test ./...with no Docker; the integration kit, which starts Postgres and the sidecar image, runs only underANKKA_SLOW=1.
A guest library¶
A guest library does what an SDK does, with the ABI in place of the gRPC services. Where an SDK serves the
protocol on a port, a guest library exports the ankka1_ functions and calls the ankka1 imports that
WebAssembly ABI lists, and the language must compile to a WebAssembly core
module with no imports but those — no WASI. The rest is the same list: discovery (returned as a WasmSpec),
the component model in the language's idiom, the encoding, and testkits. The differences to design for:
- The protocol artifact includes
WASM-ABI.mdandwasm.proto. Copy them with the rest; the Rust crate'sscripts/proto.shdoes, and continuous integration diffs its copy as it does the SDKs'. - Both guest shapes. A component is stateless unless the module lists it as stateful in
WasmSpec; a stateful component keeps its decoded state between calls untilankka1_close. The reference service reads its shape from theANKKA_CONFORMANCE_SHAPEvariable through theconfigimport, so the suite runs it in each. - No streaming. A module answers every call whole, so the library offers no streaming handler or route.
- Faults are traps. A panic or an exception in a handler should be logged through the
logimport and then trap; the runtime discards the instance and answers the caller with a fault.
The suite runs against a module with a wasm: target, in either shape:
sbt 'sidecar/testOnly *ConformanceSuite' -Dankka.conformance.target=wasm:/path/to/service.wasm
sbt 'sidecar/testOnly *ConformanceSuite' -Dankka.conformance.target=wasm:/path/to/service.wasm -Dankka.conformance.shape=stateful
The Rust crate's conformance.sh builds its reference module and runs both.
An embedded SDK¶
An embedded SDK is built in this repository, against the Scala SDK, and is released with it, because it calls the runtime's own types. The five things an SDK does read differently for it:
- Serves no protocol. The runtime calls the developer's handlers directly, on the same virtual threads
a Scala handler runs on. A service is one process and one image, with
"hosting"left at its default. - Answers no discovery. The facade reads every registered class once when the service starts and turns it into the Scala SDK's descriptors, refusing to start with every declaration problem listed. What the descriptors carry is what a Scala service's carry, so the runtime cannot tell the languages apart.
- Gives developers the component model in their language's idiom. The Java SDK declares components as classes and handlers as annotated methods whose string is the wire name, called by method reference; the Kotlin SDK adds data classes, suspending handlers and a DSL over the Java types. The rules are the same: a stable component id, wire names apart from method names, effects as values, a query that can only return a read-only effect.
- Encodes values exactly as the Scala SDK does. The facade's codec is its own, written for the
language's types, and is held to the encoding fixtures as any SDK's is. The
Java SDK's
RecordCodecFixturesSuitereads them; the Kotlin SDK keeps a copy insdks/kotlin/fixtures, which./gradlew checkFixturescompares with the original. - Ships testkits over the Scala testkits: unit kits that run one component, and a whole-service kit that starts the real runtime against Postgres. No sidecar image is involved.
The conformance suite runs an embedded reference in its own process, as it runs the Scala one, chosen by a system property:
sbt 'sidecar/testOnly *ConformanceSuite' -Dankka.conformance.reference=java
sbt 'sidecar/testOnly *ConformanceSuite' -Dankka.conformance.reference=classpath:<file>:<class>
The second form names a file listing a class path and the reference's class on it, for a reference built
outside sbt; just kotlin-conformance builds the Kotlin reference with Gradle and runs it that way. A case
the reference cannot run yet is reported skipped, with the reason, never run against another language's
components under this one's name.
Declaring a service¶
A service built with the new SDK is deployed like any other, with a descriptor that declares process hosting and the protocol version its SDK speaks:
{ "name": "cart", "service": { "image": "registry.example.com/acme/cart-rb:1.0.0", "hosting": "process", "protocol": "1.0" } }
The image holds only the process. The platform supplies the sidecar.