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. Prerequisites
Section titled “1. Prerequisites”-
Rust with the
wasm32-wasip2target:Terminal window rustup target add wasm32-wasip2 -
A development cluster.
merod localruns three nodes in one process, with mock attestation; save its first node inmeroctland sign in with a passkey kept in a file (development clusters only):Terminal window merod local# in another terminalmeroctl node add local http://127.0.0.1:2501/ --allow-mockmeroctl login --dev-passkey ./dev-passkey.json --name aliceloginsigns up when the file does not exist yet, and saves the session. See Run local nodes.
2. Create the project
Section titled “2. Create the project”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::*;3. Define the state
Section titled “3. Define the state”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.
4. Add logic, events and errors
Section titled “4. Add logic, events and errors”-
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 },} -
Add the logic block. Every app has one
#[app::init]; a&mut selfmethod 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_resultisgetResult. env::emitsends an#[app::event]value to an audience (All, the caller, an org, a team, named accounts).- An
#[app::error]variant reaches the client as422 APP_ERRORwith{ "code": "NotFound", "fields": { "key": ".." } }. u64andi64arguments and results travel as decimal strings.
- Method and argument names reach the ABI in camelCase:
5. Test it
Section titled “5. Test it”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.
6. Build and run
Section titled “6. Build and run”Build the component (from a checkout of core, or with the mero-app-build
binary):
scripts/install-mero-app-build.sh # once: puts mero-app-build on PATHmero-app-build path/to/kv-store --out kv_store.wasmIt compiles the crate for wasm32-wasip2 and lifts the ABI into the
component’s calimero:abi section, which the node reads.
-
Create a context from it. On a development cluster
--pathuploads a raw.wasm(a signed.mpkinstalls a package):Terminal window meroctl context create --name my-kv --path kv_store.wasm --alias my-kv -
Call the setter. A call without
--viewis an intent the leader logs:Terminal window meroctl call -c my-kv set --args '{"key": "hello", "value": "world"}' -
Read it back with a view:
Terminal window meroctl call -c my-kv get --args '{"key": "hello"}' --viewThe answer carries
"world"and a receipt signed by the answering node.
7. Make it multi-user
Section titled “7. Make it multi-user”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:
-
Create an organization and a context in it:
Terminal window meroctl namespace create acmemeroctl context create --name team-kv --path kv_store.wasm \--namespace <org-id> --alias team-kvA context created in an organization is reached by every member (access
org, the default there). -
Invite a member. The command prints an invitation token:
Terminal window meroctl namespace invite <org-id> --role member -
Join as the second person: a second account, with its own passkey file, on the same cluster. Save the node under another
--homeso the two sessions stay apart:Terminal window meroctl --home ./bob node add local http://127.0.0.1:2501/ --allow-mockmeroctl --home ./bob login --dev-passkey ./bob-passkey.json --name bobmeroctl --home ./bob namespace join <token> -
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.
8. Next steps
Section titled “8. Next steps”You have the full loop: write, build, install, create, call. From here: