1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# 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`
```toml
members = ["rust/src/abi", "examples/rust/*"]
```
`examples/rust/hello/Cargo.toml`
```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`
```rust
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`
```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](../server/mcp.md) 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](../compositions.md)) |
| `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
```bash run=gate
cargo wasm
```
```bash run
ls target/wasm32-unknown-unknown/release/hello.wasm
atlibs run examples/rust/hello 2>&1 | tail -1
```
```output
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`
```rust
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`
```rust
abi::call_ids! {
#[derive(Clone, Copy)]
enum Id {
List = "list",
Create = "create",
Delete(usize) = "delete",
}
}
```
`examples/rust/statusphere/src/lib.rs`
```rust
struct ClearStatus {
deletes: Fan,
deleted: u64,
failed: u64,
}
```
`examples/rust/statusphere/src/lib.rs`
```rust
// 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`
```rust
#[derive(Serialize, PartialEq)]
struct Status {
status: String,
#[serde(rename = "createdAt")]
created_at: Datetime,
}
```
`examples/rust/statusphere/src/lib.rs`
```rust
impl LexiconType for Status {
fn lexicon_type() -> &'static str {
COLLECTION
}
}
```
`examples/rust/statusphere/src/lib.rs`
```rust
step.out.call(Id::Create, create(args.did.as_str(), Status { status, created_at }));
```
## Rules
- No imports: a module exports `memory`, `alloc` and its entries, and nothing else.
- Input and output are JSON. Modules have no clock: use `now` from the input.
- Each event gets 5 s; calls, events and wall time per run are budgeted ([limits](../reference/limits.md)).
- Only `calls` NSIDs; writes only to records this host created for it, or in `anyRecords`.
Previous: [CLI](../cli.md) | Next: [Calls](calls.md)