Skip to content

Build your first app

This tutorial walks one path from nothing to a running key-value app. You will write a small Rust crate, build it to WebAssembly, install it on a node, attach it to a context, and call its methods — checking the output at each step.

The worked example mirrors the in-repo apps/kv-store app, trimmed to the parts you need the first time through. When you finish you will have a context whose state you can read and write from the command line.

  1. The cargo mero toolchain. It scaffolds, builds, tests, and bundles Calimero apps:

    Terminal window
    cargo install cargo-mero

    See the cargo mero toolchain for other install methods. cargo mero build auto-installs the wasm32-unknown-unknown target and has wasm-opt built in, so there is nothing else to set up.

  2. A running node. You need a merod node up and a configured meroctl alias pointing at it. If you have not done this yet, follow Install & run a node first. This tutorial assumes the alias node1.

  1. Scaffold the app:

    Terminal window
    cargo mero new kv-store
    cd kv-store

    This writes a ready-to-build crate: Cargo.toml, src/lib.rs (a working state + logic + a #[cfg(test)] TestHost test), and tests/converge.rs.

  2. Open the generated Cargo.toml. The load-bearing parts cargo mero new wrote for you:

    [lib]
    # cdylib is the wasm the node loads; rlib lets `cargo test` link the app.
    crate-type = ["cdylib", "rlib"]
    [dependencies]
    thiserror = "1"
    # SDK crates pinned to a git tag.
    calimero-sdk = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>" }
    calimero-storage = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>" }
    [dev-dependencies]
    # Native test harness (TestHost) + CRDT merge under `cargo test`.
    calimero-storage = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>", features = ["testing"] }
    [package.metadata.calimero]
    # Reverse-DNS app id the node installs the bundle under.
    package = "com.example.kv-store"
    name = "kv-store"
    # cargo mero build compiles under this; --profiling uses app-profiling instead.
    [profile.app-release]
    inherits = "release"
    opt-level = "z" # optimize for size: wasm ships over the wire
    lto = true
    panic = "abort" # no unwinding in wasm
    overflow-checks = true
    # Empty [workspace] makes this crate its own workspace root, so cargo reads
    # the app-* profiles (which only apply at a workspace root).
    [workspace]

    The [package.metadata.calimero] table is your bundle identity; the full set of keys is in the toolchain metadata reference.

  3. Open src/lib.rs. The scaffold already put a working key-value app there. The next sections walk through the shape it follows so you can change it with confidence; every app starts from the same imports:

    use calimero_sdk::app;
    use calimero_storage::collections::{LwwRegister, UnorderedMap};

Your app’s persistent state is a single struct annotated with #[app::state]. Its fields are CRDT collections — replicated types that merge cleanly when two nodes change them concurrently.

Here the state is a map from String keys to LwwRegister<String> values. An LwwRegister (“last-writer-wins”) holds one value and keeps the most recent write when nodes diverge.

#[app::state(emits = for<'a> Event<'a>)]
pub struct KvStore {
items: UnorderedMap<String, LwwRegister<String>>,
}

The emits = ... part declares that this app emits the Event type you define next. If your app does not emit events, you can write just #[app::state].

  1. Declare the events your app emits with #[app::event]. Events are how an app tells the outside world what changed — clients can subscribe to them:

    #[app::event]
    pub enum Event<'a> {
    Inserted { key: &'a str, value: &'a str },
    Updated { key: &'a str, value: &'a str },
    }
  2. Add the logic block. Every app needs exactly one #[app::init] that returns the initial state. Then add a setter that mutates state (&mut self) and a getter that only reads it (&self):

    #[app::logic]
    impl KvStore {
    #[app::init]
    pub fn init() -> KvStore {
    KvStore {
    items: UnorderedMap::new(),
    }
    }
    pub fn set(&mut self, key: String, value: String) -> app::Result<()> {
    app::log!("Setting key: {:?} to value: {:?}", key, value);
    if self.items.contains(&key)? {
    app::emit!(Event::Updated { key: &key, value: &value });
    } else {
    app::emit!(Event::Inserted { key: &key, value: &value });
    }
    self.items.insert(key, value.into())?;
    Ok(())
    }
    pub fn get(&self, key: &str) -> app::Result<Option<String>> {
    app::log!("Getting key: {:?}", key);
    Ok(self.items.get(key)?.map(|v| v.get().clone()))
    }
    }

    A few things to notice, all real SDK API:

    • app::log! writes to the node’s execution log.
    • app::emit! emits one of your declared events.
    • Collection calls (contains, insert, get) return Result, so they end in ?.
    • value.into() turns the String into an LwwRegister<String>; v.get() reads the register’s current value back out.

Build the app with one command:

Terminal window
cargo mero build

It compiles to wasm32-unknown-unknown under the app-release profile, size-optimizes with wasm-opt -Oz, and embeds the ABI. The artifact lands at:

res/kv_store.wasm

meroctl talks to your running node. Select it with --node node1. Each command below prints a table by default; pass --output-format json to get the machine-readable form shown here.

  1. Install the WASM module. The node returns an application id:

    Terminal window
    meroctl --node node1 app install \
    --path ./res/kv_store.wasm \
    --package com.example.kvstore \
    --version 0.1.0
    {"data":{"applicationId":"<app-id>"}}

    Copy the applicationId — you need it next. You can list installed apps any time with meroctl --node node1 app ls.

  2. Create a namespace bound to that application id. A namespace is a root group; creating one bootstraps the group you will attach a context to:

    Terminal window
    meroctl --node node1 namespace create --application-id <app-id>
    {"data":{"namespaceId":"<namespace-id>"}}

    The returned namespaceId is the id of that root group — you pass it as the --group-id in the next step.

  3. Create a context. A context is a running instance of your app over a shared state. context create needs both the application id and a group id to attach to, so pass the namespace id from the previous step. Give the context a memorable alias with --name:

    Terminal window
    meroctl --node node1 context create \
    --application-id <app-id> \
    --group-id <namespace-id> \
    --name my-kv
    {"data":{"contextId":"<context-id>","memberPublicKey":"<public-key>"}}

    You now have a live context. The --name my-kv alias lets you refer to it as my-kv instead of the raw contextId.

  4. Call the setter to write a key. Mutations and views both go through the top-level call command — whether a call mutates or only reads is decided by the method itself, not a CLI flag. --args is JSON matching the method’s parameters:

    Terminal window
    meroctl --node node1 call set \
    --context my-kv \
    --args '{"key": "hello", "value": "world"}' \
    --output-format json

    set returns (), so the result output is null:

    {"jsonrpc":"2.0","id":null,"result":{"output":null}}
  5. Call the getter to read it back:

    Terminal window
    meroctl --node node1 call get \
    --context my-kv \
    --args '{"key": "hello"}' \
    --output-format json
    {"jsonrpc":"2.0","id":null,"result":{"output":"world"}}

    The value you stored comes back under result.output. Your first app works.

Here is what that single call set does once it reaches the node — the method runs inside the WASM app, touches storage, emits its event, and the committed delta is what later flows to peers:

So far one node owns the context. The payoff of a Calimero app is that a second node can hold the same context and the two converge automatically — that is the “replicas converge” divider in the diagram above. With a second node node2 running, bring it into your context:

  1. On node1 (the context owner), create a group invitation for the namespace. A namespace is its own group, so pass the namespaceId from step 6 as the group id. The command prints an invitation JSON blob:

    Terminal window
    meroctl --node node1 group invite <namespace-id>
  2. On node2, redeem that blob to join the group, then join the context itself:

    Terminal window
    meroctl --node node2 group join '<invitation-json>'
    meroctl --node node2 group join-context <context-id>
  3. Now both nodes hold the context. Write from node2 and read it back on node1 — once the delta has synced, the value is there:

    Terminal window
    meroctl --node node2 call set \
    --context <context-id> --args '{"key": "from", "value": "node2"}'
    meroctl --node node1 call get \
    --context <context-id> --args '{"key": "from"}' --output-format json
    {"jsonrpc":"2.0","id":null,"result":{"output":"node2"}}

Both nodes wrote and read the same key with no central server. Because items is a CRDT map, concurrent writes from both nodes merge instead of clobbering — that is what makes the convergence safe, not just eventual.

You have the full loop: write, build, install, attach, call. From here: