Skip to content

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.

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.

Terminal window
V=<release> T=x86_64-unknown-linux-gnu # the core release, and this machine's target
curl -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 -c
tar -xzf "meroctl_$T.tar.gz" -C ~/.local/bin meroctl
Terminal window
meroctl app build apps/kv-store --out kv-store.wasm
meroctl app codegen --abi kv-store.wasm --lang ts -o src/generated
wrote src/generated/openapi.json
wrote 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.

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.

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.

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),
});

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.

from calimero import Client
from generated.client import KvStoreClient
kv = KvStoreClient(client, app_id) # a signed-in calimero Client
kv.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 method

Arguments 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=.

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).

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 method
val 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).

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.

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.

Compare the new build with the last release:

Terminal window
meroctl app abi diff dist/kv-store-1.0.0.mpk kv-store.wasm
breaking method get: result changed: option<string> -> string
compatible 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:

Terminal window
meroctl app sign manifest.json --key release.key --previous dist/kv-store-1.0.0.mpk
meroctl 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.mpk

Breaking 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).