- name
- Capsule Forge
- description
- Use when creating, building, scaffolding, or authoring an Unicity AOS capsule — a sandboxed WASM tool provider that exposes tools to the LLM over a message bus. The complete, self-contained guide: the minimal file set, the #[capsule]/#[astrid::tool] macros, Capsule.toml and the capability/bus ACL, the build→install→call loop, and every footgun. Everything needed to ship a capsule from zero.
# Capsule Forge — Author an Unicity AOS Capsule From Zero
You are about to write a **capsule**: a small WebAssembly Component, compiled
from Rust, that the Astrid kernel loads into a sandbox and lets it expose
**tools** to the LLM over an event bus. You need no prior AOS knowledge —
**this page is the whole map.** Everything you need to write the WIT references,
the `Capsule.toml`, and the Rust is here. You should never have to leave it.
> **The one sentence that anchors everything:** A capsule is a sandboxed WASM
> tool provider that talks *only* over a message bus. The kernel is **dumb** — it
> routes events, enforces capabilities, and runs the sandbox; it contains no tool
> logic. **All intelligence lives in capsules.** Your `Capsule.toml`'s
> `[publish]`/`[subscribe]` keys are not config — they *are* the capability ACL
> the kernel enforces, and they are **fail-closed**: a topic you don't list, you
> cannot touch.
---
## Table of contents
1. 60-second quickstart
2. What you are actually building
3. The minimal file set (copy verbatim)
4. The macro reference (`#[capsule]`, `#[astrid::tool]`, lifecycle hooks)
5. The SDK surface (`astrid_sdk::*`)
6. `Capsule.toml` — the complete manifest reference
7. The capability catalog (lists vs. bools — get this right)
8. WIT / interface references at author depth
9. How a tool call actually flows (the IPC lifecycle)
10. The three topic matchers (the conceptual footgun)
11. The build → install → call dev loop
12. Footguns (read before you waste an hour)
13. Security mindset
14. Design principles (write small capsules)
15. The forge tools
---
## 1. 60-Second Quickstart
Get a complete, *compiling* skeleton one of three ways, then build → install →
call:
- **Forge tool (available when Unicity AOS is running with the forge capsule
installed — it ships with Unicity AOS):** call
`scaffold_capsule { "name": "my-capsule" }`. It returns a JSON map of
`path -> file content` for a complete skeleton — write each file out.
- **CLI:** `aos capsule new my-capsule` scaffolds the same full project
(`.cargo/config.toml`, `rust-toolchain.toml`, `Cargo.toml`, `Capsule.toml`,
`src/lib.rs`, `README.md`) ready to `cargo build` on the first try.
- **By hand:** copy the files in section 3, substituting your name.
Then:
```bash
# 1. Install the WASM target once (the scaffold pins it in rust-toolchain.toml):
rustup target add wasm32-unknown-unknown
# 2. Build the capsule. `aos capsule build` produces ./dist/<name>.capsule.
# Plain `cargo build` also works — .cargo/config.toml selects the target,
# so DO NOT pass --target.
aos capsule build
# 3. Install it into the running daemon (content-addressed — see footgun 4):
aos capsule install ./dist/my-capsule.capsule
# 4. Verify it loaded:
aos capsule list # your capsule should appear
aos status # daemon should still be healthy
# 5. Ask the LLM to call your tool.
```
---
## 2. What You Are Actually Building
```
my-capsule/
├── .cargo/config.toml # selects the wasm target + the getrandom flag (#1 footgun)
├── rust-toolchain.toml # pins the toolchain + the wasm target
├── Cargo.toml # cdylib crate, depends on astrid-sdk
├── Capsule.toml # the manifest — capabilities + the bus ACL
└── src/
└── lib.rs # your tools
```
A capsule:
- runs in a **WASM sandbox** (`wasm32-unknown-unknown`, Component Model) with
**zero `wasi:*` imports**. Every host call is an audited `astrid:*` call routed
through the SDK. The WIT import list *is* the capsule's literal capability list.
- talks to the world **only over the bus**. It cannot open a socket, read a file,
or spawn a process except through a capability its manifest declares and the
kernel grants. Any undeclared syscall is denied before it reaches the bus.
- exposes **tools** the LLM can call. A tool is just a Rust method; the
`#[astrid::tool]` macro wires it onto the bus and publishes a JSON-schema
description of it so the model discovers it.
There is also a **per-principal sandbox**: `home://` resolves to *the calling
principal's* home, and `__state`/KV are auto-scoped per capsule **and** per
principal. Two users invoking your capsule never see each other's data.
---
## 3. The Minimal File Set (copy verbatim, substitute the name)
### `.cargo/config.toml`
```toml
[build]
target = "wasm32-unknown-unknown"
[target.wasm32-unknown-unknown]
# THE #1 FOOTGUN. Activates astrid-sys's custom getrandom backend, which routes
# entropy to the host's astrid:sys.random-bytes. WITHOUT THIS, anything pulling
# getrandom — uuid::v4, HashMap's RandomState seed — fails to LINK on
# wasm32-unknown-unknown. A library CANNOT set this for its dependents; it MUST
# live here, in the final capsule crate. This is the most common confusing build
# failure for new authors.
rustflags = ["--cfg=getrandom_backend=\"custom\""]
```
### `rust-toolchain.toml`
```toml
[toolchain]
channel = "1.95.0"
targets = ["wasm32-unknown-unknown"]
components = ["rustfmt", "clippy"]
```
### `Cargo.toml`
```toml
[package]
name = "my-capsule"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false
[lib]
crate-type = ["cdylib"] # capsules are cdylib — NOT bin, NOT the default rlib
[dependencies]
# 0.7 resolves to 0.7.1+, which carries the tool_describe publish fix (a
# return-only describe collects zero tools), so the floor matters.
astrid-sdk = { version = "0.7", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = "z" # optimise for size — capsules ship as wasm
lto = true
codegen-units = 1
strip = true
panic = "abort"
```
### `Capsule.toml`
```toml
[package]
name = "my-capsule" # lowercase ASCII alphanumeric + hyphens only
version = "0.1.0" # MUST be three-part numeric MAJOR.MINOR.PATCH
description = "What this capsule does"
authors = ["Your Name <you@example.com>"]
astrid-version = ">=0.7.0" # semver requirement against the host runtime
[[component]]
id = "my-capsule"
file = "my_capsule.wasm" # crate name with hyphens -> underscores, + .wasm
type = "executable" # "executable" (default) or "library"
[capabilities]
# Declare ONLY what you use. Least capability. (See section 7.)
fs_read = ["home://"]
# --- The mandatory tool-bus ACL. Without these two publish keys, tool results
# never return and the describe fan-out cannot answer. ---
[publish]
"tool.v1.execute.*.result" = { wit = "@unicity-astrid/wit/types/tool-call-result" }
"tool.v1.response.describe.*" = { wit = "@unicity-astrid/wit/tool/describe-response" }
# One subscribe row per tool, plus the describe request. The `handler` is
# `tool_execute_<tool_name>`. `tool_describe` is AUTO-GENERATED by the macro —
# you declare it here but never write it.
[subscribe]
"tool.v1.execute.hello" = { wit = "@unicity-astrid/wit/types/tool-call", handler = "tool_execute_hello" }
"tool.v1.request.describe" = { wit = "@unicity-astrid/wit/tool/describe-request", handler = "tool_describe" }
```
### `src/lib.rs`
```rust
#![deny(unsafe_code)]
#![deny(clippy::all)]
use astrid_sdk::prelude::*; // capsule, SysError, fs, ipc, kv, http, log, runtime, ...
use astrid_sdk::schemars; // for #[derive(schemars::JsonSchema)] on the args type
use serde::Deserialize;
#[derive(Default)] // the struct MUST derive Default
pub struct MyCapsule;
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct HelloArgs {
/// Who to greet (this doc-comment becomes the parameter description the LLM sees).
pub name: String,
}
#[capsule]
impl MyCapsule {
/// Greet someone. This method doc-comment becomes the tool description the
/// LLM sees, so write it for the model.
#[astrid::tool("hello")]
pub fn hello(&self, args: HelloArgs) -> Result<String, SysError> {
Ok(format!("Hello, {}!", args.name))
}
}
```
> Canonical contracts referenced as `@unicity-astrid/wit/...` are resolved at
> build time and need no local copy. If the capsule defines its own public WIT
> interface, check that source into `wit/`; the capsule builder includes it in
> the installable archive so consumers can discover and subscribe to it.
---
## 4. The Macro Reference
Everything lives behind one prelude import:
```rust
use astrid_sdk::prelude::*; // brings capsule, SysError, and the modules: fs,
// ipc, kv, http, log, runtime, env, time, net,
// process, identity, approval, capabilities, ...
use astrid_sdk::schemars; // for #[derive(schemars::JsonSchema)] on args
```
### `#[capsule]` — on the `impl` block
`#[capsule]` (or `#[capsule(state)]` for stateful mode) goes on your `impl Struct`
block. It generates the four WASM exports the kernel calls
(`astrid_hook_trigger`, `run`, `astrid_install`, `astrid_upgrade`), the
`export!()` wiring, the auto `tool_describe`, and a panic hook that routes guest
panics to `log::error`. **The struct must `#[derive(Default)]`** — this is
enforced by a generated assertion.
### `#[astrid::tool]` — on a method
Accepted forms:
```rust
#[astrid::tool("name")] // explicit tool name
#[astrid::tool("name", mutable)] // explicit name, marked mutable
#[astrid::tool(mutable)] // name inferred from the method name, mutable
#[astrid::tool] // name inferred, not mutable
```
The method signature is:
```rust
fn name(&self, args: ArgsType) -> Result<T, E>
```
where:
- **`ArgsType`** derives `serde::Deserialize + schemars::JsonSchema` (and
`Default`). The JsonSchema derive produces the parameter schema the LLM sees;
field doc-comments become parameter descriptions.
- **`T: serde::Serialize`** — `String` is the common choice (return JSON or
markdown text). Anything `Serialize` works.
- **`E: Display`** — use `SysError`. The error's `Display` string is returned to
the model as the tool's error content.
- **The method doc-comment becomes the tool description** shown to the LLM.
- **`mutable`** (or a `&mut self` receiver) flags a state-mutating tool. The flag
rides in the tool schema so the approval layer can present the right copy; it
does **not** change routing or auto-gate anything. With `&mut self`, the macro
loads `self` from KV before the call and persists it after (on success only) —
see "Stateful mode" below.
Behind the scenes: a tool named `foo` becomes an interceptor handler
`tool_execute_foo`, dispatched when an event arrives on `tool.v1.execute.foo`,
and its result is published on `tool.v1.execute.foo.result`.
### Lifecycle hooks (each is a singleton; duplicates are a compile error)
- **`#[astrid::install]`** — `fn(&self) -> Result<(), SysError>`. Runs once at
`aos capsule install`, *before* the capsule enters the normal runtime.
This is the only place `elicit` works (interactive secret/value prompting).
**This is also how a Skill lands on disk** — see footgun 5.
- **`#[astrid::upgrade]`** — `fn(&self, prev_version: &str) -> Result<(), SysError>`.
Runs on version upgrade; receives the previous version string.
- **`#[astrid::run]`** — `fn(&self) -> Result<(), SysError>`. A long-lived
background loop (rare — most capsules are pure tool providers and need none).
A run-loop capsule must call `runtime::signal_ready()` once it is initialized,
and should `recv` regularly (the kernel epoch-interrupts a run loop that burns
CPU without yielding). `run` never auto-persists state.
- **`#[astrid::interceptor("topic")]`** — `fn(&self, payload: Vec<u8>) -> Result<InterceptResult, SysError>`.
The raw event handler. Tools are sugar over this. Returns
`InterceptResult::Continue(payload)` / `Final(payload)` / `Deny { reason }`. A
`handler =` in a `[subscribe]` row binds a topic to one of these.
- **`#[astrid::command("name")]`** — registers a slash-command handler (dispatched
like a tool, but not surfaced as an LLM tool / no describe entry).
### Stateful mode
Two ways to make a capsule stateful: put `#[capsule(state)]` on the impl block, or
take `&mut self` on any handler. Then:
- before each handler, the macro loads your struct from KV key `__state`
(`kv::get_json("__state")`), falling back to `Default` on a decode error;
- after a **successful** handler call, it persists with
`kv::set_json("__state", &self)` (skipped on error, so a failure never writes
partial state);
- `#[astrid::install]` starts from `Default` and persists the result;
`#[astrid::upgrade]` starts from the saved state (or `Default`);
- `#[astrid::run]` loads once at startup and **never** auto-saves — manage your
own persistence with explicit `kv::set_json(...)` calls.
Stateless capsules (`&self`, no `state`) use an in-memory `OnceLock<T>` singleton
and never touch KV.
---
## 5. The SDK Surface (`astrid_sdk`)
View on GitHub