Write a module
A module is a Rust crate built to wasm32-unknown-unknown, written against the abi crate
(rust/src/abi). Each entry is a plain function from the run's input and event to a Handle: the
calls to make, the new state, or the result. It has no I/O of its own.
A module crate
examples/rust/hello counts an account's records in a collection. Any directory under examples/rust/
is a workspace member:
Cargo.toml
members = ["rust/src/abi", "examples/rust/*"]
examples/rust/hello/Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
abi.workspace = true
serde.workspace = true
serde_json.workspace = true
examples/rust/hello/src/lib.rs
use abi::{Call, Did, Event, Handle, Run, entries};
use serde::Deserialize;
use serde_json::json;
// Every event gets the action's input plus did and now; Run adds the event and the last state.
#[derive(Deserialize)]
struct Args {
did: Did,
#[serde(default)]
collection: Option<String>,
}
fn count_handler(run: Run<Args>) -> Result<Handle, String> {
let collection = run.args.collection.ok_or("collection is required")?;
Ok(match run.event {
// First event: list the records. service = the account's DID: a public read of its PDS.
Event::Start => Handle::new().call(
Call::new("list", "com.atproto.repo.listRecords")
.params(json!({ "repo": run.args.did, "collection": collection, "limit": "100" }))
.service(run.args.did.as_str()),
),
// The result comes back with the id we gave it. A failed call is the run's error.
Event::Result { id, result, .. } if id == "list" => {
let records = result.output()?["records"].as_array().map_or(0, Vec::len);
Handle::new().result(json!({ "collection": collection, "records": records })).done()
}
_ => Handle::new(),
})
}
entries!(count => count_handler);
entries! exports alloc and one function per entry. Did rejects anything that is not a DID.
module.json
examples/rust/hello/module.json
{
"name": "Hello",
"description": "Count an account's records in a collection.",
"actions": [{ "entry": "count", "label": "Count", "fields": [{ "key": "collection", "label": "Collection" }] }],
"calls": ["com.atproto.repo.listRecords"]
}
| field | what |
|---|---|
name, description | shown by serve, pages and the published record |
actions | the entries people run: entry, label, description, readOnly (changes none of the user's data), fields (keyed by input key: label, type, required, description); MCP servers serve them as tools |
calls | the NSIDs the module may call; anything else is refused |
session | the rkey of its com.fedfork.exec.oauth.clientMetadata record (records/<session>.json): the scope a signed-in run gets. No session: public reads only |
anyRecords | collections it may update or delete records in even if this host did not create them |
hooks, implements, anyHooks | calls to and from other modules: a route reaches the entry its target implements (Compositions) |
config | the input a composition's instance may give it, as fields |
didkey | { "services": [...] }: the host gives the module its own did:key |
Build and run it
cargo wasm
ls target/wasm32-unknown-unknown/release/hello.wasm
atlibs run examples/rust/hello 2>&1 | tail -1
target/wasm32-unknown-unknown/release/hello.wasm
hello entries: count
Builds are always release builds: a debug module is ~20 MB, past the 4 MB a host loads.
Events and handles
Run<Args> carries args (the input: your fields plus did and now), state (what you returned
last, null at first) and event:
rust/src/abi/src/lib.rs
pub enum Event {
Start,
/// A call finished (or was refused); `result.output()` is its output or error.
Result {
id: String,
nsid: String,
result: CallResult,
},
/// One message on an open subscription; `dropped` counts messages lost before it.
Message {
id: String,
data: Value,
dropped: u64,
},
/// A subscription ended; `error` is None for a clean close.
Closed {
id: String,
error: Option<String>,
},
Timer {
id: String,
},
/// An external event fed into the run.
Input {
data: Value,
},
/// A capability this run passed in a call's input was invoked: `id` is that call,
/// `cap` the name the run gave it. Answer by calling `reply` (a capability) with the answer as input.
Callback {
id: String,
cap: String,
input: Value,
reply: String,
},
/// An event type newer than this crate.
Unknown,
}
A Handle says what happens next:
| method | does |
|---|---|
.call(Call::new(id, nsid)...) | make an XRPC call; its result arrives as Event::Result { id } |
.timer(id, ms) | Event::Timer { id } after ms |
.send(id, data) / .cancel(id) | write up / close a subscription, or cancel a call |
.state(value) | the state the next event gets |
.result(value) | the run's answer |
.done() | end the run now (otherwise it ends when nothing is pending) |
An Err(String) from a handler ends the run with that error.
State, typed ids and fan-out
For more than one or two calls, Step keeps typed state and typed call ids, and Fan collects a batch
of results that arrive one by one. statusphere's clear_status lists statuses, deletes each, and
counts:
examples/rust/statusphere/src/lib.rs
abi::call_ids! {
#[derive(Clone, Copy)]
enum Id {
List = "list",
Create = "create",
Delete(usize) = "delete",
}
}
examples/rust/statusphere/src/lib.rs
struct ClearStatus {
deletes: Fan,
deleted: u64,
failed: u64,
}
examples/rust/statusphere/src/lib.rs
// list up to CLEAR_LIMIT statuses -> delete each (results arrive one by one) -> count.
fn clear_status_handler(run: Run<Args>) -> Result<Handle, String> {
let (mut step, on) = Step::<Args, ClearStatus, Id>::new(run)?;
let did = step.args.did.as_str();
match on {
On::Start => step.out.call(Id::List, list_statuses(&step.args.did, CLEAR_LIMIT)),
On::Result(Id::List, result) => {
let output = result?;
let rkeys: Vec<String> = output["records"].as_array().into_iter().flatten().filter_map(|record| Some(record["uri"].as_str()?.parse::<AtUri>().ok()?.rkey)).take(CLEAR_LIMIT).collect();
if rkeys.is_empty() {
step.out.finish(json!({ "deleted": 0 }));
return step.handle();
}
let deletes = rkeys.iter().enumerate().map(|(i, rkey)| (Id::Delete(i), delete_record(&AtUri::new(did, COLLECTION, rkey))));
step.out.fan_out(&mut step.state.deletes, deletes);
}
On::Result(id @ Id::Delete(_), result) => {
let s = &mut step.state;
if result.is_ok() { s.deleted += 1 } else { s.failed += 1 }
if s.deletes.land(&id, result).is_some() {
step.out.finish(json!({ "deleted": s.deleted, "failed": s.failed }));
}
}
_ => {}
}
step.handle()
}
Records
abi::repo builds the repo calls; a type with LexiconType is written with its $type:
examples/rust/statusphere/src/lib.rs
#[derive(Serialize, PartialEq)]
struct Status {
status: String,
#[serde(rename = "createdAt")]
created_at: Datetime,
}
examples/rust/statusphere/src/lib.rs
impl LexiconType for Status {
fn lexicon_type() -> &'static str {
COLLECTION
}
}
examples/rust/statusphere/src/lib.rs
step.out.call(Id::Create, create(args.did.as_str(), Status { status, created_at }));
Rules
- No imports: a module exports
memory,allocand its entries, and nothing else. - Input and output are JSON. Modules have no clock: use
nowfrom the input. - Each event gets 5 s; calls, events and wall time per run are budgeted (limits).
- Only
callsNSIDs; writes only to records this host created for it, or inanyRecords.