Typed clients for your app
Task: call your app’s methods from a frontend or a script with types checked by the compiler, and find out before publishing whether a new version breaks the clients already out there.
Every app embeds its ABI: its methods, their parameters
and results, its errors and events. meroctl app codegen turns it into an
OpenAPI 3.1 document and a thin typed client over the client you already use:
mero-js’s CalimeroClient (its client.apps.call and client.apps.view),
calimero-client-py’s Client (the same, answering JSON), or the CalimeroClient
and ContextContractCalling of swift-sdk (MeroKit) and kotlin-sdk (mero-core). They keep doing the
work (the attested, sealed channel, sessions, passkey step-ups, retries); the
generated code only adds the method names and types.
Generate a client
Section titled “Generate a client”Put meroctl on PATH once, build the app, then point codegen at the
component, the .mpk bundle or an ABI JSON file (meroctl context abi saves an
installed app’s). Every release ships meroctl prebuilt for Linux (x86_64,
aarch64) and macOS (aarch64), named meroctl_<target>.tar.gz and listed in the
release’s SHA256SUMS, so CI that only runs codegen needs no Rust toolchain.
meroctl --version names the release it came from.
V=<release> T=x86_64-unknown-linux-gnu # the core release, and this machine's targetcurl -fsSLO "https://github.com/calimero-network/core/releases/download/$V/meroctl_$T.tar.gz"curl -fsSL "https://github.com/calimero-network/core/releases/download/$V/SHA256SUMS" \ | grep " meroctl_$T.tar.gz\$" | sha256sum -ctar -xzf "meroctl_$T.tar.gz" -C ~/.local/bin meroctlmeroctl app build apps/kv-store --out kv-store.wasmmeroctl app codegen --abi kv-store.wasm --lang ts -o src/generatedwrote src/generated/openapi.jsonwrote src/generated/client.ts--lang is ts, python, swift, kotlin or openapi (the document alone).
Every language writes openapi.json beside the client (client.ts, client.py,
KvStoreClient.swift, KvStoreClient.kt) unless --no-openapi says not to.
The ABI carries no app name, so the client class is named after --name, else
the package of an .mpk, else the file’s name (kv-store.wasm gives
KvStoreClient). A file named for what it holds (abi.json,
app.component.wasm) names no app: the class is then named after the crate whose
Cargo.toml is nearest above the file (logic/res/abi.json beside
logic/Cargo.toml’s name = "kv-store"), and with no such crate codegen stops
and asks for --name. A Kotlin client goes in --package (default
calimero.generated.<name>, here calimero.generated.kvstore). A multi-service
bundle writes one directory per service, each Kotlin client in <package>.<service>;
--service <name> picks one. Pass --kotlin-serialization-plugin when the Kotlin
module that compiles the client applies the kotlinx.serialization compiler plugin
(below). meroctl app codegen --help prints the usage.
Use it from TypeScript
Section titled “Use it from TypeScript”import { CalimeroClient, passkeyConfirm } from '@calimero-network/mero-js';import { KvStoreClient } from './generated/client';
const client = /* a signed-in CalimeroClient */;const kv = new KvStoreClient(client, appId);
await kv.set({ key: 'greeting', value: 'hello' });const value: string | null = await kv.get({ key: 'greeting' });const count: string = await kv.len(); // u64: a decimal string
for await (const event of kv.events()) { if (event.kind === 'Inserted') console.log(event.data.key, event.data.value);}A call takes { idempotencyKey, confirm, signal } as its last argument, a view
{ consistency, minIndex, signal } (consistency: 'stale' reads the replica that
answers). A confirm method’s 412 CONFIRM_REQUIRED step-up is answered by the
call’s confirm, else by the confirm the CalimeroClient was built with
(here rooms is apps/rooms’ generated client):
await rooms.adminRelease({ id, reason }, { confirm: passkeyConfirm(passkey) });An option<..> argument may be left out or be null: the SDK reads both as
None. Every generated client leaves out an optional argument the caller does
not set: TypeScript sends the args object as given (a key left out stays out),
and the Python, Swift and Kotlin methods leave out a None / nil / null
argument rather than sending null. In an error’s or an event’s fields it is
always there, null for none.
Read a call’s log index and events
Section titled “Read a call’s log index and events”Each method answers its result alone. The same methods under raw answer the
whole answer: a call { result, index, events } (CallAnswer<T>: the log index
it committed at and the app’s events it emitted, typed as AppEvent), a view
{ result, index } (ViewAnswer<T>: the log index of the state it read). Use
them to resume the event stream from what a call or view already reflects, or to
read a later view at least as new as a call:
const { index, events } = await kv.raw.set({ key: 'greeting', value: 'hello' });const value = await kv.get({ key: 'greeting' }, { consistency: 'stale', minIndex: index });
// Render the entries, then follow the events after the state they reflect.const { result: entries, index: seen } = await kv.raw.entries();for await (const event of kv.events(seen + 1)) { /* ... */ }raw takes the same arguments and options, and runs after the same way. Its
type is <Name>RawApi (KvStoreRawApi). If the app has a method named raw,
the property is rawAnswers.
Create an instance with its init arguments
Section titled “Create an instance with its init arguments”When the app’s #[app::init] takes arguments (here mero-chess’s
init(title: Option<String>, now: Option<u64>)), the client declares them as
InitArgs, in the types of every other argument (a u64 is a decimal string, an
option may be left out). client.apps.create takes initArgs as any JSON, so
check it with satisfies:
import type { InitArgs } from './generated/client';
const { appId } = await client.apps.create({ package: 'com.calimero.mero-chess', initArgs: { title: 'Friday blitz', now: String(Date.now()) } satisfies InitArgs,});An init without arguments declares no InitArgs.
Keep a refreshed session
Section titled “Keep a refreshed session”mero-js refreshes an expired session on its own, and has no event that says it
did. A page that stores the session passes after, which runs after every call
and view, refused ones included:
const kv = new KvStoreClient(client, appId, { after: () => saveSession(client.session),});Test without a node
Section titled “Test without a node”The class implements KvStoreApi (<Name>Api), an interface of the app’s
methods alone. Code that takes a KvStoreApi, or the Pick of it that it calls,
is handed the class in the app and a double of those methods in a test; there
is no need to Pick from the class itself (Pick<KvStoreClient, 'len'>).
Code that reads raw takes KvStoreRawApi the same way (kv.raw in the app):
import type { KvStoreApi, KvStoreRawApi } from './generated/client';
function Counter({ kv }: { kv: Pick<KvStoreApi, 'len'> }) { /* ... */ }
render(<Counter kv={{ len: async () => '3' }} />);
async function save(kv: Pick<KvStoreRawApi, 'set'>): Promise<number> { return (await kv.set({ key: 'a', value: 'b' })).index;}
await save({ set: async () => ({ result: null, index: 7, events: [] }) });To test the class itself, give it a double of the part of CalimeroClient it
uses. A client that only calls and views takes an AppCallTransport
(apps.call and apps.view; Pick<CalimeroClient, 'apps'> is one), and has no
events(): calling it does not type-check. events() needs an AppTransport,
which adds events:
const kv = new KvStoreClient( { apps: { call: async () => ({ result: null }), view: async ({ method }) => ({ result: method === 'len' ? '0' : null }), }, }, 'app-1',);A double may answer { result } alone; it answers raw with index 0 and no
events unless it also gives index (and a call’s events), as mero-js does.
KvStoreClient written without a type argument is a client with events
(KvStoreClient<AppTransport>), which is what one built on a CalimeroClient is.
Use it from Python
Section titled “Use it from Python”from calimero import Clientfrom generated.client import KvStoreClient
kv = KvStoreClient(client, app_id) # a signed-in calimero Clientkv.set("greeting", "hello")assert kv.get("greeting") == "hello"assert kv.len(consistency="stale") == "1"rooms.admin_release("b-1", "late", passkey=passkey) # apps/rooms' confirm methodArguments are keyword arguments in snake_case (room_id=); errors and events
are TypedDicts (NotFoundError, InsertedEvent). A call also takes
idempotency_key=, a confirm method passkey= (else the client’s own), and a
view consistency= and min_index=.
Use it from Swift
Section titled “Use it from Swift”The client is a Sendable struct over swift-sdk’s MeroKit, 2.0 or later (import MeroKit):
import MeroKit
// Every method: idempotency keys, stale views, `confirm` step-ups, events.let kv = KvStoreClient(client: calimeroClient, app: appId)// Or the contract an app already holds (MeroAppSession's backend, a test double):let kv = KvStoreClient(backend: session.backend!, app: appId)
try await kv.set(key: "greeting", value: "hello")let value: String? = try await kv.get(key: "greeting")let count: UInt64 = try await kv.len() // u64: a decimal string on the wire
for try await frame in kv.events() { if case .inserted(let key, let value) = frame.event { print(key, value) }}
do { try await kv.remove(key: "missing")} catch let error as AppError { if case .notFound = KvStoreClient.Refusal(error) { /* ... */ }}A call takes idempotencyKey: last, a view stale: and minIndex:, a confirm
method passkey:. Over a ContextContractCalling (init(contract:),
init(backend:app:)) a view is query and a call execute; confirm methods,
events() and an idempotencyKey need init(client:app:) and throw
MeroAppBackendUnsupported otherwise. Decode MeroAppBackend.events with
KvStoreClient.Event(kind:data:). Put each client in a module of its own (one
target per service).
Use it from Kotlin
Section titled “Use it from Kotlin”The client class is over kotlin-sdk’s mero-core, 2.0 or later, with kotlinx.serialization and kotlinx.coroutines on the classpath (no serialization plugin needed):
val kv = KvStoreClient(calimeroClient, appId) // every methodval kv = KvStoreClient(backend, appId) // MeroAppBackend's contract
kv.set(key = "greeting", value = "hello")val value: String? = kv.get(key = "greeting")val count: ULong = kv.len()
kv.events().collect { frame -> when (val event = frame.event) { is KvStoreClient.Event.Inserted -> println("${event.key} ${event.value}") else -> {} }}
try { kv.remove(key = "missing")} catch (e: AppError) { if (KvStoreClient.Refusal.from(e) is KvStoreClient.Refusal.NotFound) { /* ... */ }}The trailing parameters and the two ways to build it are Swift’s:
(client, app) runs every method; (contract) and (backend, app) run calls and
views and throw UnsupportedOperationException for the rest. Decode
MeroAppBackend.events with KvStoreClient.Event.decode(kind, data).
How the types come out
Section titled “How the types come out”In TypeScript and Python u64 and i64 are decimal strings and bytes base64url
strings, as on the wire; Swift and Kotlin take and give UInt64/ULong,
Int64/Long and Data/ByteArray and write the wire forms themselves.
u64_number / i64_number (a #[abi(plain)] field) are JSON numbers: number in
TypeScript (exact only up to 2^53), int in Python, and the same UInt64/ULong,
Int64/Long written as numbers; byte_list is number[], List[int],
[UInt8], List<UByte>. option<T> is T | null (Optional[T], T?) and an
optional argument may be left out (every client leaves out one that is not set). Errors and events are typed:
NotFoundError / InsertedEvent in TypeScript and Python, the cases of
Refusal and Event in Swift and Kotlin. The full table is in
App interface.
Your own types come from the ABI’s types: a record is an interface (a
TypedDict), an enum a union of its variants as serde writes them ('Open',
{ Closed: { at: string } }, { type: 'published'; at: string }), and a generic
type stays generic in TypeScript (Listing<Slot>) and is instantiated in Python
(Listing_Slot).
In Swift and Kotlin they are types nested in the client (KvStoreClient.Slot),
whose coding writes the same JSON:
| ABI shape | Swift | Kotlin |
|---|---|---|
| record | struct with a var per field, Codable, Sendable, Equatable |
data class, its serializer Slot.serializer() |
newtype / transparent |
struct with value |
data class with value |
| tuple struct / unit struct | struct with v0, v1, … / with nothing (null) |
data class with v0, v1, … / data object |
| enum | enum, a case per variant (.closed(at:), .moved(_:)) |
sealed interface, a data class or data object per variant |
generic (Listing<T>) |
generic (Listing<Slot>) |
generic (Listing.serializer(Slot.serializer())) |
Each enum reads and writes the tagging serde gives it: externally tagged as
"Open" or { "Closed": { .. } } ({ "Open": null } is read too), internally
tagged as the variant’s fields beside its tag (a newtype variant’s own object),
adjacently tagged as { tag, content } (a unit variant without content), and
untagged as the data alone, trying the variants in order as serde does. Unknown
variant names and arrays of the wrong length are refused. A type that holds itself
works: a Swift enum that does is indirect, and a Swift field that holds its own
type outside an array or a dictionary (option<Tree>) is stored boxed
(@Indirect); Kotlin needs nothing. Fields the JSON may leave out are optional
(nil / null; Python’s TypedDict puts them in a total=False part, so a caller
may leave them out) and left out when unset; any other option is written as
null. Kotlin’s serializers are written out in the client, so no serialization
plugin is needed: pass them where kotlinx.serialization takes one. Each type is
also annotated @Serializable(with = ...) naming its serializer, so
serializer<Slot>() finds it, sealed interfaces too: by reflection on the
companion’s serializer(), which kotlinx’s bundled R8 rules keep for
@Serializable classes. A module that applies the kotlinx.serialization compiler
plugin generates with --kotlin-serialization-plugin: the plugin then writes each
serializer() itself (a hand-written one would clash with it, “Overload resolution
ambiguity”) and resolves serializer<T>() without reflection; call sites
(Slot.serializer()) stay the same.
Use any OpenAPI tool
Section titled “Use any OpenAPI tool”openapi.json is a standard OpenAPI 3.1 document: each method is
POST /apps/{app}/methods/{name}, and its body is { args, idempotencyKey? } for a
call or { args, consistency?, minIndex? } for a view. A running node serves the
same document for an installed app at GET /apps/{app}/openapi.json. Generators
and validators read it as they read any other; give the generated code mero-js
or calimero-client-py as its transport, since the node answers only inside the
sealed channel.
Check a new version before publishing
Section titled “Check a new version before publishing”Compare the new build with the last release:
meroctl app abi diff dist/kv-store-1.0.0.mpk kv-store.wasmbreaking method get: result changed: option<string> -> stringcompatible method count: added (view)It exits 1 when a change breaks existing callers, so it can gate CI. The rules
are in App interface.
meroctl app abi show <file> prints either ABI whole. Signing runs the same check when you name the last release:
meroctl app sign manifest.json --key release.key --previous dist/kv-store-1.0.0.mpkmeroctl app pack --wasm kv-store.wasm --package com.example.kv-store \ --app-version 1.1.0 --previous dist/kv-store-1.0.0.mpk --out dist/kv-store-1.1.0.mpkBreaking changes are printed as a warning. A state field removed or changed in
kind while the state version stayed the same is refused: bump
#[app::state(version = N)] and migrate the data
(Upgrades & state).