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. Prerequisites
Section titled “1. Prerequisites”-
Rust with the WASM target. Calimero apps compile to
wasm32-unknown-unknown:Terminal window rustup target add wasm32-unknown-unknown -
A running node. You need a
merodnode up and a configuredmeroctlalias pointing at it. If you have not done this yet, follow Install & run a node first. This tutorial assumes the aliasnode1.
2. Create the project
Section titled “2. Create the project”-
Create a new library crate:
Terminal window cargo new --lib kv-storecd kv-store -
Replace
Cargo.toml. The load-bearing parts are thecdylibcrate type (so the crate compiles to a WASM module), the two Calimero dependencies, and theapp-releaseprofile that the build step uses:[package]name = "kv-store"version = "0.1.0"edition = "2021"[lib]crate-type = ["cdylib", "rlib"][dependencies]calimero-sdk = { git = "https://github.com/calimero-network/core" }calimero-storage = { git = "https://github.com/calimero-network/core" }thiserror = "1"[dev-dependencies]# Native test harness (TestHost) + CRDT merge under `cargo test`.calimero-storage = { git = "https://github.com/calimero-network/core", features = ["testing"] }[profile.app-release]inherits = "release"codegen-units = 1opt-level = "z"lto = truedebug = falsestrip = "symbols"panic = "abort"overflow-checks = true -
Open
src/lib.rsand clear it. You will fill it in over the next three steps. The skeleton every Calimero app follows is: imports, a state struct, a logic block with aninit, and method definitions.use calimero_sdk::app;use calimero_storage::collections::{LwwRegister, UnorderedMap};
3. Define the state
Section titled “3. Define the state”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].
4. Add logic and events
Section titled “4. Add logic and events”-
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 },} -
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) returnResult, so they end in?. value.into()turns theStringinto anLwwRegister<String>;v.get()reads the register’s current value back out.
5. Build to WASM
Section titled “5. Build to WASM”Compile the crate for the WASM target using the app-release profile:
cargo build --target wasm32-unknown-unknown --profile app-releaseThe artifact lands at:
target/wasm32-unknown-unknown/app-release/kv_store.wasmOptionally shrink it with wasm-opt (this is exactly what the example’s
build.sh does when wasm-opt is installed):
wasm-opt -Oz --enable-bulk-memory \ target/wasm32-unknown-unknown/app-release/kv_store.wasm \ -o kv_store.wasm6. Install and run
Section titled “6. Install and run”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.
-
Install the WASM module. The node returns an application id:
Terminal window meroctl --node node1 app install \--path ./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 withmeroctl --node node1 app ls. -
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
namespaceIdis the id of that root group — you pass it as the--group-idin the next step. -
Create a context. A context is a running instance of your app over a shared state.
context createneeds 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-kvalias lets you refer to it asmy-kvinstead of the rawcontextId. -
Call the setter to write a key. Mutations and views both go through the top-level
callcommand — whether a call mutates or only reads is decided by the method itself, not a CLI flag.--argsis JSON matching the method’s parameters:Terminal window meroctl --node node1 call set \--context my-kv \--args '{"key": "hello", "value": "world"}' \--output-format jsonsetreturns(), so the result output isnull:{"jsonrpc":"2.0","id":null,"result":{"output":null}} -
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:
7. Make it multi-user
Section titled “7. Make it multi-user”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:
-
On
node1(the context owner), create a group invitation for the namespace. A namespace is its own group, so pass thenamespaceIdfrom step 6 as the group id. The command prints an invitation JSON blob:Terminal window meroctl --node node1 group invite <namespace-id> -
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> -
Now both nodes hold the context. Write from
node2and read it back onnode1— 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.
8. Next steps
Section titled “8. Next steps”You have the full loop: write, build, install, attach, call. From here: