Skip to content

Build your first app

This tutorial walks one path from nothing to a running key-value app on the component SDK (calimero-sdk): you write a small Rust crate, test it on the real engine, build it into a calimero:app component, create a context from it, and call its methods, checking the output at each step.

The worked example is the in-repo apps/kv-store app, trimmed to the parts you need the first time through.

  1. Rust with the wasm32-wasip2 target:

    Terminal window
    rustup target add wasm32-wasip2
  2. A development cluster. merod local runs three nodes in one process, with mock attestation; save its first node in meroctl and sign in with a passkey kept in a file (development clusters only):

    Terminal window
    merod local
    # in another terminal
    meroctl node add local http://127.0.0.1:2501/ --allow-mock
    meroctl login --dev-passkey ./dev-passkey.json --name alice

    login signs up when the file does not exist yet, and saves the session. See Run local nodes.

A component app is a standalone cdylib crate that depends on the SDK and nothing else from Calimero:

[package]
name = "kv-store"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
calimero-sdk = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>" }
# The test harness (TestApp) and the builder, for `cargo test` on the host.
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
calimero-sdk = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>", features = ["testing"] }
calimero-app-build = { git = "https://github.com/calimero-network/core", tag = "<sdk-tag>" }
serde_json = "1"
# Its own workspace root.
[workspace]

Every module starts from one import:

use calimero_sdk::prelude::*;

State is a struct of handles annotated with #[app::state]; loading it reads nothing, and each field names its own keys in the app’s storage. Records live in a Table, declared with #[derive(Record)] and a #[table] attribute naming the key (and any indexes, unique constraints and counts):

#[derive(Record)]
#[table(key = "key")]
pub struct Entry {
pub key: String,
pub value: String,
}
#[app::state(version = 1)]
pub struct KvStore {
entries: Table<Entry>,
size: Counter,
}

The app runs on its cluster’s leader, which orders every write in the app’s log, so a later set of a key replaces an earlier one; there is nothing to merge.

  1. Declare the events and errors inside the #[app::logic] module, where the ABI picks them up:

    #[app::event]
    pub enum Event {
    Inserted { key: String, value: String },
    Updated { key: String, value: String },
    }
    #[app::error]
    pub enum Error {
    NotFound { key: String },
    }
  2. Add the logic block. Every app has one #[app::init]; a &mut self method is a call, a #[app::view] reads:

    #[app::logic]
    impl KvStore {
    #[app::init]
    pub fn init() -> app::Result<Self> {
    Ok(Self::load())
    }
    pub fn set(&mut self, key: String, value: String) -> app::Result<()> {
    let event = if self.entries.contains(&key)? {
    Event::Updated { key: key.clone(), value: value.clone() }
    } else {
    self.size.add(1)?;
    Event::Inserted { key: key.clone(), value: value.clone() }
    };
    self.entries.upsert(Entry { key, value })?;
    env::emit(event, Audience::All)?;
    Ok(())
    }
    #[app::view]
    pub fn get(&self, key: String) -> app::Result<Option<String>> {
    Ok(self.entries.get(&key)?.map(|e| e.value))
    }
    #[app::view]
    pub fn get_result(&self, key: String) -> app::Result<String> {
    match self.entries.get(&key)? {
    Some(e) => Ok(e.value),
    None => Err(Error::NotFound { key }.into()),
    }
    }
    }

    A few things to notice:

    • Method and argument names reach the ABI in camelCase: get_result is getResult.
    • env::emit sends an #[app::event] value to an audience (All, the caller, an org, a team, named accounts).
    • An #[app::error] variant reaches the client as 422 APP_ERROR with { "code": "NotFound", "fields": { "key": ".." } }.
    • u64 and i64 arguments and results travel as decimal strings.

calimero_sdk::testing::TestApp runs the built component on the real engine in an org you describe, as named accounts, with the node’s own checks:

use calimero_app_build::build_component;
use calimero_sdk::testing::{Org, TestApp};
use serde_json::json;
#[test]
fn set_then_get() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let target = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("guest");
let component = build_component(dir, &target).unwrap().component;
let mut t = TestApp::new(&component, Org::new().members(["bob"]), 1_700_000_000_000);
t.as_("bob").call("set", json!({ "key": "k", "value": "v" })).unwrap();
assert_eq!(t.as_("owner").view("get", json!({ "key": "k" })).unwrap(), json!("v"));
assert_eq!(t.events_for("owner")[0].0, "Inserted");
}

The same steps can live in a scenario file, scenarios/<name>.json, which calimero_sdk::testing::run_scenario runs in the harness and the cluster runner runs on real nodes (see Testing):

[
{ "as": "bob", "op": "call", "method": "set", "args": { "key": "k", "value": "v" } },
{ "as": "owner", "op": "view", "method": "get", "args": { "key": "k" }, "expect": { "ok": "v" } },
{ "as": "owner", "op": "view", "method": "getResult", "args": { "key": "x" },
"expect": { "error": "NotFound", "fields": { "key": "x" } } }
]

Run both with cargo test.

Build the component (from a checkout of core, or with the mero-app-build binary):

Terminal window
scripts/install-mero-app-build.sh # once: puts mero-app-build on PATH
mero-app-build path/to/kv-store --out kv_store.wasm

It compiles the crate for wasm32-wasip2 and lifts the ABI into the component’s calimero:abi section, which the node reads.

  1. Create a context from it. On a development cluster --path uploads a raw .wasm (a signed .mpk installs a package):

    Terminal window
    meroctl context create --name my-kv --path kv_store.wasm --alias my-kv
  2. Call the setter. A call without --view is an intent the leader logs:

    Terminal window
    meroctl call -c my-kv set --args '{"key": "hello", "value": "world"}'
  3. Read it back with a view:

    Terminal window
    meroctl call -c my-kv get --args '{"key": "hello"}' --view

    The answer carries "world" and a receipt signed by the answering node.

A context belongs to an organization, and everyone in it reaches the context. Create one, create the context in it, and invite a second person:

  1. Create an organization and a context in it:

    Terminal window
    meroctl namespace create acme
    meroctl context create --name team-kv --path kv_store.wasm \
    --namespace <org-id> --alias team-kv

    A context created in an organization is reached by every member (access org, the default there).

  2. Invite a member. The command prints an invitation token:

    Terminal window
    meroctl namespace invite <org-id> --role member
  3. Join as the second person: a second account, with its own passkey file, on the same cluster. Save the node under another --home so the two sessions stay apart:

    Terminal window
    meroctl --home ./bob node add local http://127.0.0.1:2501/ --allow-mock
    meroctl --home ./bob login --dev-passkey ./bob-passkey.json --name bob
    meroctl --home ./bob namespace join <token>
  4. Write as one, read as the other:

    Terminal window
    meroctl --home ./bob call -c <context-id> set --args '{"key": "from", "value": "bob"}'
    meroctl call -c team-kv get --args '{"key": "from"}' --view
    { "index": 2, "result": "bob", "receipt": { … } }

Both people wrote and read the same state, each with their own passkey, through any node of the cluster. The organization decides who reaches the context; what each member may change inside it is the app’s own code and its access control. See Organizations, teams & access.

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