Skip to content

Call your app from a client

Task: call the methods of an app you deployed, from a web page, a script, a service or the command line, and read its events as they happen.

Every client does the same five things, which is why you use one of the Calimero clients rather than plain HTTP:

  1. Attest the node: fetch its TDX quote and check it against a policy you give (the signed mero-tee release it names, at least a minimum version) before sending anything.
  2. Seal every request end to end to the attested key (Noise, calimero/sealed-http/v2), so nothing between you and the enclave reads it.
  3. Authenticate with a passkey session (people) or an API key (machines).
  4. Call the app’s routes: POST /apps/{app}/intents for a logged call, POST /apps/{app}/views for a read, GET /apps/{app}/events for the event stream.
  5. Retry a call the node answered 503 with the same idempotency key, so it runs once even if the first attempt committed.
Client Where Package
mero-js browser, Node 18+ @calimero-network/mero-js (docs)
calimero-client-py CPython 3.9+ calimero-client-py (docs)
calimero-client Rust crates/client in this repository
meroctl the command line crates/meroctl (reference)

All four send the same requests over the same sealed channel. For methods and types generated from your app’s ABI on top of mero-js or the Python client, see Typed clients for your app.

import { CalimeroClient, browserPasskey } from '@calimero-network/mero-js';
// A Calimero Cloud cluster by name: its nodes come from the public directory,
// and each is attested under your policy before anything is sent.
const client = await CalimeroClient.connect({
cluster: 'eu-shared-1',
policy: { release: { minReleaseVersion: '2.3.109' } },
});
// Or nodes you name: new CalimeroClient({ baseUrl, nodes, policy }).
// A development cluster (merod local, merod run --dev): policy: { allowMock: true }.
await client.signIn(browserPasskey()); // a person, in a browser
client.useApiKey('cal_...'); // or a machine
  • Passkeys are for people, in a browser (browserPasskey()); a passkey belongs to one cluster, and one made elsewhere is 401 PASSKEY_UNKNOWN_HERE. A page served from the app’s own frontend gets a session bound to its package: see Ship a frontend.
  • API keys are for scripts and services. An account holder creates one with a passkey step-up (meroctl account api-key create [--context C]... [--read-only] [--ttl-secs N], or client.auth.apikeyCreate in mero-js); the key is shown once. --context limits it to some apps and --read-only to views.
  • Sessions refresh themselves: a 401 on a session the client holds the refresh token of is refreshed once and the request sent again.
  • 421 WRONG_NODE means the node runs a release the account’s owners have not approved. mero-js and the Python client move to the next node they were given; the Rust client returns Error::WrongNode for you to do so.
const { index, result, events } = await client.apps.call({
app, method: 'set', args: { key: 'a', value: '1' }, idempotencyKey: 'set-a-1',
});
const { result: value } = await client.apps.view({ app, method: 'get', args: { key: 'a' } });
const fast = await client.apps.view({ app, method: 'get', args: { key: 'a' }, consistency: 'stale', minIndex: index });
  • Names and arguments are the ABI’s: methods in camelCase, arguments a JSON object keyed by camelCase parameter names ({} for none; unknown keys are refused). u64 and i64 go as decimal strings and bytes as base64url without padding, in both directions (App interface).
  • A call answers { index, result, events, receipt }: the log index it committed at, the method’s result, the events of that call the caller may see, and the answering node’s signed receipt. Arguments are at most 512 KiB (413 ARGS_TOO_LARGE).
  • A view is linearizable by default (consistency: "strong"): the replica answering it first applies the index the app’s leader names. stale reads the node you reached, and minIndex makes it wait until it has applied that index (503 NOT_CAUGHT_UP if it has not), which is how a client reads its own write cheaply.
  • Idempotency. A call with a key is answered once: yours is kept 24 hours, and mero-js and the Rust client make one when you give none (kept 10 minutes). They resend a call with the same key while the node answers 503 or cannot be reached (6 retries from 200 ms doubling to 2 s; mero-js then moves to its next node); the Python client leaves such a node for its next one. A call that did commit is answered from its record, never run twice. Give your own key when the call must not repeat across restarts of your program.
  • Calling an action (#[app::action]) only starts it: the result is { "actionId": "a.…" } and the work happens later on the leader (Timers and actions).

A method marked #[app::confirm] needs the caller’s passkey on every call: the node answers 412 CONFIRM_REQUIRED with a challenge over that exact call, and the client sends it again with the assertion, which is logged as consent. An API key cannot answer it.

import { passkeyConfirm, browserPasskey } from '@calimero-network/mero-js';
await client.apps.call({ app, method: 'clear', args: {} }, { confirm: passkeyConfirm(browserPasskey()) });
client.use_passkey(passkey) # answers each 412
client.apps.call(app=app_id, method="clear", args={})

In Rust the last argument of apps().call is Some(&mut passkey); meroctl call takes --dev-passkey FILE on a development cluster.

Status, code Meaning mero-js
422 APP_ERROR the app refused: details.code is the #[app::error] variant (or the node’s ABI check: UnknownMethod, WrongKind, NotAView, Forbidden, ConfirmRequired), details.fields its fields AppError (appCode, fields)
422 TRAP the run trapped (details.kind: out of fuel, host denied, …); nothing was written TrapError
401 UNAUTHENTICATED no or expired credential, or an anonymous call of a method that is not public LoginRequired
404 NOT_FOUND no such app, or one you may not reach (the same answer) NotFound
412 CONFIRM_REQUIRED a confirm method, sent without a passkey assertion ConfirmRequired
413 ARGS_TOO_LARGE arguments over 512 KiB ArgsTooLarge
403 FORBIDDEN a read-only team member’s call that would write, or a credential that may not do this CalimeroError
429 QUOTA_EXCEEDED the app’s developer is over its fuel for the day QuotaExceeded
503 the node could not serve it now (NOT_CAUGHT_UP, no leader, a timeout); the clients retry UnavailableError (committed: false or 'unknown')
400 RESERVED_METHOD a method name starting __ CalimeroError

Python raises CloudError(message, status, code, details) and Rust returns Error::Api { status, code, .. }; meroctl prints the code and message. The app’s own error codes and fields are in its ABI (meroctl context abi).

GET /apps/{app}/events?from=N is a server-sent event stream: the committed events from log index N, then live ones, each sent only to the accounts its audience names. Read the state with a view, then follow from the view’s index + 1, so you miss nothing and see nothing twice.

const stop = new AbortController();
for await (const { id, kind, data } of client.events(app, index + 1, stop.signal)) {
console.log(id, kind, data);
}

A node serves at most 4,096 event and presence streams together (429 RATE_LIMITED past that). What the app emits and who receives it: Emit events. Live state that is not an event, such as cursors: Show who is here.

Every call and view answer carries receipt { app, index, resultHash, node, key, signature }: the answering node’s Ed25519 signature over the app, the index and the SHA-256 of the result’s canonical JSON. A receipt proves the result to someone else, later, offline, but only under a key the app’s replicas attest: verify_app_cluster attests every replica over a fresh nonce and answers their receipt keys and the cluster’s governance.

let cluster = client.verify_app_cluster(&app).await?;
let key = cluster.keys.get(&set.receipt.node).expect("an attested replica");
assert!(set.receipt.verify(key, &set.result));

Python has client.verify_app_cluster(app), and meroctl attest app CONTEXT prints the same.