Skip to main content

capsule-forge

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.

Source facts

Repository
unicity-aos/oracles
Last source activity
July 20, 2026 at 02:43
Detected SKILL.md language
English
Stars
2
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub