Writing apps for a cluster
An app is a WASM module built with the Rust SDK (calimero-sdk, a core
module that runs on wasmer) or a calimero:app component (calimero-app-sdk,
run on wasmtime). Build covers writing one; this page covers what
it sees once it runs on a cluster.
On a cluster
Section titled “On a cluster”-
Packages. Install a signed
.mpkbypackage@versionfrom the operator’s registry (POST /packages/install,meroctl app install PACKAGE@VERSION); a file (POST /packages) only on a development cluster. Every node that runs a release fetches it and checks the publisher’s signature, the artifact digests and the hash the fleet recorded; a package keeps the publisher key of its first release. Create an instance withPOST /apps { package, version }, upgrade with{ version }, and delete withDELETE /apps/{app}. Raw WASM uploads (POST /bundles) are for development clusters only too. -
Instances. An app instance (what
meroctlcalls a context) is created withPOST /apps { name, bundle, initArgs, org?, access?, confirmMethods? }.initArgs(JSON) is what#[app::init]receives; a failed init answers422 INIT_FAILEDinstead of leaving a dead instance. -
Identity. There are no devices:
env::account_id()andenv::executor_id()are both the caller’s account, so apps that key people by either tell them apart. The context id isSHA-256(app_id). -
Organizations.
env::caller_org()returns the caller’s standing in the app’s organization —org,role(Guest < Member < Admin < Owner) and the team ids they are in — orNone. The node already keeps non-members out of an organization’s apps; this is for rules inside the app:use calimero_sdk::env::{self, OrgRole};pub fn set_pinned(&mut self, text: String) -> app::Result<()> {if !env::caller_org().is_some_and(|o| o.role >= OrgRole::Admin) {app::bail!("admins only");}self.pinned.set(text);Ok(())}apps/org-gatedin core is a complete example;TestHost::set_caller_orgdrives it in unit tests. -
Teams. A team is open (whoever reaches its parent, or the organization at the top, is a member) or restricted (its members only), nests and moves, keeps the app’s metadata (a folder’s name and colour), and each member’s row has a role — lead, member or read-only — and capability bits. Deploy an app to a team with
access: { kind: "team", team }: its members reach it, and a read-only member’s calls run but one that would write is refused (403). A team they neither reach nor manage is invisible to them. Deleting a team deletes the teams and apps below it. -
Devices are passkeys.
env::account_id()is the person; each passkey on the account is one of their devices (POST /auth/passkeys/add/*, and/auth/menames the session’spasskeyId). -
Blobs.
env::blob_*store and open content-addressed bytes. A blob belongs to the app that wrote or received it (POST /apps/{app}/blobs,GET /apps/{app}/blobs/{id}, ids base58 or hex); every node holds it, so announcing one is a no-op. -
Search.
#[derive(app::Searchable)]andapp::search_indexes!declare full-text indexes: views query the app’s own index, kept up to date from the log before the view runs. A declared#[app::view]called as a call runs as a view. -
Event handlers and xcalls run once, after the entry that asked for them, on the app’s leader — not once per replica. Only
#[app::handler]methods run for an event; an xcall reaches only#[app::xcall]entry points (from_same_app: the same bundle) of apps in the caller’s organization, as the calling app, withenv::xcall_origin(). -
Timers and TEE runs. The cluster is every app’s TEE:
#[app::tee(every = "..")]methods fire once per period,tee:handlers run as the TEE, andTeeOnly/TeeSecretstate works; a member’s share opens only in their own calls. -
Upgrades.
POST /apps/{app}/upgrade { version }(or{ bundle }on a development cluster) plans the migration from the two bundles’ embedded ABIs (#[app::state(version)]and#[app::migrate]), runs your migration check, and refuses a downgrade or a missing edge. Build bundles withcargo mero buildso they carry their ABI: handlers, xcall entry points, timers and migrations are read from it. -
Outbound HTTPS (
env::http_request). A call (never a view, init or migration, nor a read-only member’s call) may send one request under a prefix its signed manifest declares (outboundin[package.metadata.calimero]), to a host the operator allows, once that prefix was approved for the publisher by whoever the cluster’s deployment gave outbound approvals (its governance, whichverify_app_clusterreports). By the owner:POST /orgs/{org}/outbound(or/auth/outboundfor your own apps){ package, prefixes: "manifest" | [...], replace }, with a passkey assertion (approve_outboundin the Rust client). By the operator:POST /ops/outbound. Until then the request is refused withNotApproved(orAwaitingApprovalfor a prefix a later release added), which the app can show. Secrets the operator holds ({{secret:NAME}}) are filled in by the node and redacted from the answer. -
Confirmations. Methods listed in
confirmMethodsneed a fresh passkey assertion over the exact call (callConfirmedin the clients). -
Ordering. Calls on one instance are applied in one order, so a write never merges with a concurrent one: CRDT collections still work, and plain values do too. There are no offline replicas: a client that cannot reach the cluster cannot write, so there are never concurrent edits to reconcile later.
Components
Section titled “Components”A calimero:app component gets calls, views, events, timers, upgrades
(code-only) and its organization’s standing. The node refuses it blobs,
search, xcalls, event handlers, TEE runs and outbound HTTPS for now, and an
app cannot move between the two runtimes with an upgrade. See
Application execution.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Bundle size | 16 MiB |
| One call’s log entry (args + state diff + events) | 1 MiB |
| Apps, bundle bytes, API keys, organizations per account | quotas, set by the operator (429 QUOTA_EXCEEDED) |