| 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
- 60-second quickstart
- What you are actually building
- The minimal file set (copy verbatim)
- The macro reference (
#[capsule], #[astrid::tool], lifecycle hooks)
- The SDK surface (
astrid_sdk::*)
Capsule.toml — the complete manifest reference
- The capability catalog (lists vs. bools — get this right)
- WIT / interface references at author depth
- How a tool call actually flows (the IPC lifecycle)
- The three topic matchers (the conceptual footgun)
- The build → install → call dev loop
- Footguns (read before you waste an hour)
- Security mindset
- Design principles (write small capsules)
- 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:
rustup target add wasm32-unknown-unknown
aos capsule build
aos capsule install ./dist/my-capsule.capsule
aos capsule list
aos status
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
[build]
target = "wasm32-unknown-unknown"
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=getrandom_backend=\"custom\""]
rust-toolchain.toml
[toolchain]
channel = "1.95.0"
targets = ["wasm32-unknown-unknown"]
components = ["rustfmt", "clippy"]
Cargo.toml
[package]
name = "my-capsule"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
astrid-sdk = { version = "0.7", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
panic = "abort"
Capsule.toml
[package]
name = "my-capsule"
version = "0.1.0"
description = "What this capsule does"
authors = ["Your Name <you@example.com>"]
astrid-version = ">=0.7.0"
[[component]]
id = "my-capsule"
file = "my_capsule.wasm"
type = "executable"
[capabilities]
fs_read = ["home://"]
[publish]
"tool.v1.execute.*.result" = { wit = "@unicity-astrid/wit/types/tool-call-result" }
"tool.v1.response.describe.*" = { wit = "@unicity-astrid/wit/tool/describe-response" }
[subscribe]
"tool.v1.execute.hello" = { wit = "@unicity-astrid/wit/types/tool-call", handler = "tool_execute_hello" }
= { wit = , handler = }
src/lib.rs
#![deny(unsafe_code)]
#![deny(clippy::all)]
use astrid_sdk::prelude::*;
use astrid_sdk::schemars;
use serde::Deserialize;
#[derive(Default)]
pub struct MyCapsule;
#[derive(Debug, Default, Deserialize, schemars::JsonSchema)]
pub struct HelloArgs {
pub name: String,
}
#[capsule]
impl MyCapsule {
#[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:
use astrid_sdk::prelude::*;
use astrid_sdk::schemars;
#[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:
#[astrid::tool("name")]
#[astrid::tool("name", mutable)]
#[astrid::tool(mutable)]
#[astrid::tool]
The method signature is:
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)
The prelude re-exports SysError and every module below; you can write fs::read(...)
after use astrid_sdk::prelude::*, or fully-qualify as astrid_sdk::fs::read(...).
Almost every function returns Result<_, SysError>.
SysError variants: HostError(String) (any host-call failure — all typed
host error codes collapse here), JsonError, BorshError, ApiError(String)
(your own logic errors — SysError::ApiError("…".into())).
fs — VFS file I/O (paths are VFS schemes, e.g. home://...)
These are live and cover the common cases:
fs::exists(path) -> Result<bool>
fs::read(path) -> Result<Vec<u8>>
fs::read_to_string(path) -> Result<String>
fs::write(path, contents: &[u8]) -> Result<()>
fs::create_dir(path) -> Result<()>
fs::create_dir_all(path) -> Result<()>
fs::remove_file(path) -> Result<()>
fs::read_dir(path) -> Result<ReadDir>
fs::metadata(path) -> Result<Metadata>
The SDK also exposes fs::append, fs::copy, fs::rename, fs::remove_dir_all,
fs::canonicalize, fs::read_link, fs::hard_link, fs::symlink_metadata, and a
fs::File offset-I/O handle — but several of these are host-stubbed today and
return a "port pending" error. For a new capsule, stick to read/write/read_dir/
metadata/create_dir_all/remove_file and read whole files at once.
kv — per-(capsule, principal) key-value store (no capability needed)
kv::get_json::<T>(key) -> Result<T>
kv::get_json_opt::<T>(key) -> Result<Option<T>>
kv::set_json::<T>(key, &value) -> Result<()>
kv::get_bytes(key) / kv::get_bytes_opt(key) / kv::set_bytes(key, &[u8])
kv::delete(key) -> Result<()>
kv::list_keys(prefix) -> Result<Vec<String>>
kv::clear_prefix(prefix) -> Result<u64>
kv::cas(key, expected: Option<&[u8]>, new: &[u8]) -> Result<bool>
http — outbound HTTP (needs net capability)
let resp = http::send(&http::Request::get("https://api.example.com")
.header("authorization", "Bearer …")
.json(&body)?)?;
resp.status() -> u16; resp.is_success() -> bool;
resp.text() -> Result<&str>; resp.json::<T>() -> Result<T>; resp.bytes() -> &[u8]
ipc — the event bus (publish/subscribe; the heart of capsule comms)
ipc::publish(topic, payload: &str) -> Result<()>
ipc::publish_json::<T>(topic, &payload) -> Result<()>
ipc::subscribe(topic_pattern) -> Result<Subscription>
sub.recv(timeout_ms) -> Result<PollResult>
sub.poll() -> Result<PollResult>
ipc::request_response::<Req, Resp>(req_topic, resp_namespace, &req, timeout_ms) -> Result<Resp>
A PollResult carries messages: Vec<Message>; each Message has
topic, payload, source_id, and principal: PrincipalAttribution
(Verified(_) / Claimed(_) / System). For any sensitive action, gate on
message.principal.verified() per message — a recv batch can mix publishers.
Note: a recv timeout returns Ok with an empty message list, not an
error — treat an empty PollResult as the timeout signal.
log — structured logging (infallible; lands in ~/.aos/runtime/log/)
log::trace(msg); log::debug(msg); log::info(msg); log::warn(msg); log::error(msg);
runtime
runtime::signal_ready() -> Result<()>
runtime::caller() -> Result<CallerContext>
runtime::random_bytes(len) -> Result<Vec<u8>>
runtime::socket_path() -> Result<String>
Other modules (use only with the matching capability)
process (needs host_process) — process::Command::new("git").arg("status").spawn()? -> Output
(.stdout/.stderr/.exit); .spawn_background()? -> Process (RAII, drop reaps);
.spawn_persistent() needs allow_persistent. Per-capsule cap 8 concurrent.
net (needs net_connect / net_bind) — TcpStream, TcpListener,
UnixListener. Raw sockets; rare.
identity (needs identity) — resolve, link, unlink, list_links,
create_user.
approval — approval::request(action, resource) -> Result<bool> blocks for
human approval (or hits an existing allowance). No capability required.
elicit — interactive prompting; only valid inside an install/upgrade
hook. elicit::secret(key, desc), elicit::text(...), elicit::select(...),
elicit::array(...).
env — env::var(key) -> Result<String>, env::var_opt(key). Reads the
per-invocation, per-principal capsule config (declared in [env]). Never
cache the result in a OnceLock/static — env::var resolves the active
principal's overlay each call; a global cache pins one principal's value for all.
capabilities — capabilities::enumerate() -> Vec<String> (your own held
capability names, infallible); capabilities::check(uuid, cap).
time — time::now(), time::sleep(duration), time::monotonic().
6. Capsule.toml — The Complete Manifest Reference
The manifest is the authoritative, declarative source of truth. The kernel reads
it before touching a byte of WASM. Every section below is optional except
[package] (needs name + version) and a [[component]] for a WASM capsule.
[package]
[package]
name = "my-http-capsule"
version = "0.1.0"
description = "HTTP fetch tool"
authors = ["Name <e@mail>"]
astrid-version = ">=0.7.0"
license = "MIT OR Apache-2.0"
repository = "https://github.com/…"
keywords = ["http", "fetch"]
categories = ["networking"]
publish = true
[[component]]
One per WASM binary (a capsule usually has exactly one).
[[component]]
id = "http-tools"
file = "astrid_capsule_http.wasm"
type = "executable"
[publish] and [subscribe] — the IPC surface AND the ACL
Each key is an IPC topic name or wildcard pattern; the keys are exactly the
kernel's IPC ACL (effective_ipc_publish_patterns / _subscribe_patterns).
A capsule may publish only to topics matching a [publish] key and subscribe only
to topics matching a [subscribe] key — anything else is denied. There is no
separate ACL array.
Each value carries a typed WIT payload reference, in short or long form:
[publish]
"tool.v1.execute.*.result" = "@unicity-astrid/wit/types/tool-call-result"
"tool.v1.response.describe.*" = { wit = "@unicity-astrid/wit/tool/describe-response" }
[subscribe]
"tool.v1.execute.fetch_url" = { wit = "@unicity-astrid/wit/types/tool-call", handler = "tool_execute_fetch_url" }
"tool.v1.request.describe" = { wit = "@unicity-astrid/wit/tool/describe-request", handler = "tool_describe" }
The wit value may be:
- an
@scope/repo/<iface>/<record> reference (the standard tool-bus form), or
- a bare local record name (resolved from your own
wit/ — rare for tool capsules), or
- the literal
"opaque" — declares the ACL but waives payload type-checking. Used
by uplink/proxy capsules that forward bytes they do not own.
On a [subscribe] entry, handler = "..." binds the topic to a generated
WASM export (your #[astrid::tool]/#[astrid::interceptor] method, or the auto
tool_describe). An optional priority (u32, default 100, lower fires first)
orders the interceptor chain. A [subscribe] entry without a handler is
ACL-only — it grants you the right to ipc::subscribe() that topic at runtime,
but binds no export.
The mandatory tool-bus boilerplate for any tool capsule is always:
[publish]: tool.v1.execute.*.result (so results return) and
tool.v1.response.describe.* (so the describe fan-out can answer).
[subscribe]: one tool.v1.execute.<tool> per tool (handler
tool_execute_<tool>) plus tool.v1.request.describe (handler
tool_describe). tool_describe is auto-generated — list it, never write it.
If [publish] or [subscribe] is empty/missing, the capsule cannot talk on
the bus at all. Fail-closed means silence, not a loud error.
[capabilities]
What the capsule may ask of the OS. Every field is fail-closed (empty list or
false). See section 7 for the full catalog and the list-vs-bool table.
[imports] / [exports] — the WIT interface contract
Declares which astrid:* interfaces (or another capsule's exported interface)
this capsule depends on or provides. Two equivalent surface forms:
[imports]
"astrid:llm" = "^1.0"
"astrid:kv" = { version = "^1.0", optional = true }
[exports]
"astrid:llm" = "1.0.0"
[imports.astrid]
llm = "^1.0"
Most pure tool capsules need neither — the tool-bus topics are a convention, not
a WIT import. You need [imports]/[exports] only when you provide or consume a
typed interface (e.g. an LLM provider exports astrid:llm). Uplink capsules
may not declare [imports] (the loader rejects it).
[env] — capsule configuration, elicited at install
[env]
API_KEY = { type = "secret", request = "Enter your API key", placeholder = "sk-..." }
REGION = { type = "select", enum_values = ["us-east-1", "eu-west-1"], default = "us-east-1" }
TAGS = { type = "array", request = "Comma-separated tags" }
NAME = { type = "text", request = "Your name", default = "Agent" }
type is secret | text | select | array. secret is masked at the
install prompt and stored 0600 in ~/.aos/runtime/secrets/ (never returned to the
guest as plaintext via the env path); the others land in per-principal env JSON.
Read values at runtime with env::var("API_KEY"). scope is operator-only
(skip_deserializing) — a manifest cannot set it; the kernel decides per-agent
vs. shared from operator action. (The forge validate_manifest warns if you try.)
Other sections (declarative, less common)
[[command]] — slash-command registrations.
[[mcp_server]] — stdio MCP servers (the "airlock override"; command must be
in host_process; this breaks out of the WASM sandbox into a host process).
[[context_file]], [[uplink]], [[tool]], [[topic]] (legacy).
Agent Skills are user-space instructions, not a Capsule.toml protocol. Vendor a
trigger through the host plugin or add it to the agent-level Skills service;
serve capsule-owned detailed guidance through an ordinary IPC tool.
7. The Capability Catalog (lists vs. bools — get this right)
Declare only what you use (least capability). The kernel's ManifestSecurityGate
enforces these at every host call; an undeclared capability is denied before it
reaches the bus. The single most-missed detail: some keys take a list,
some take a bool. Getting this wrong is a parse/semantics error.
| Key | Type | Grants |
|---|
uplink | bool | Act as a long-lived uplink/daemon; enables publish_as. Disables the WASM timeout. |
net | list | Outbound HTTP via http. Entries are hostnames or "*". ["api.openai.com"], ["*"]. |
net_connect | list | Raw outbound TCP. Each entry "host:port" or "host:*" (no DNS wildcards). |
net_bind | list | Bind a listening socket. ["unix:*"]. Rare. |
kv | list | Reserved (declared, NOT yet gate-enforced). Per-capsule KV already works without it (auto-scoped per capsule + principal). Use kv = [] or omit. |
fs_read | list | Read under the given VFS prefixes. ["home://"]. |
fs_write | list | Write under the given prefixes. ["home://documents/"]. |
host_process | list | Spawn the named host binaries via process. ["git", "cargo"]. The "airlock override". |
allow_persistent | bool | Operator sub-grant on top of host_process: allow persistent (instance-outliving) child processes. |
identity | list | Identity ops. Values: "resolve" < "link" < "admin" (each implies the lesser). ["resolve"]. |
allow_prompt_injection | bool | Hook output may modify the system prompt. Off by default — unprivileged capsules cannot inject system-prompt instructions. |
[capabilities]
fs_read = ["home://"]
net = ["api.example.com"]
host_process = ["git"]
VFS schemes for fs_* prefixes:
home:// — the calling principal's home directory, resolved per-invocation
(per-principal isolation; most common).
cwd:// — the capsule's install directory, resolved at construction.
"*" — workspace-confined (broad; prefer a narrow prefix). Does not grant
whole-filesystem access. Paths containing .. are always rejected.
8. WIT / Interface References at Author Depth
You almost never hand-write WIT for a tool capsule. What you need to know:
-
WIT is the contract surface — the host ABI (astrid:fs, astrid:ipc,
astrid:kv, astrid:http, astrid:sys, astrid:process, astrid:identity,
…) is a set of frozen @1.0.0 interfaces. The SDK wraps them; you call the SDK.
-
The wit = "@unicity-astrid/wit/..." references in [publish]/[subscribe]
name the payload type for a topic. For tool capsules they are always exactly
these four — copy them verbatim:
| Topic role | WIT reference |
|---|
| tool execute request (subscribe) | @unicity-astrid/wit/types/tool-call |
| tool result (publish) | @unicity-astrid/wit/types/tool-call-result |
| describe request (subscribe) | @unicity-astrid/wit/tool/describe-request |
| describe response (publish) | @unicity-astrid/wit/tool/describe-response |
-
The tool payloads are simple. A request carries call_id, tool_name,
arguments (JSON). A result is ToolCallResult { call_id, content: String, is_error: bool } — there is no structured error type; you produce a string and a
boolean. The SDK macro builds these for you from your Result<T, E>.
-
wit = "opaque" waives type-checking for uplink/proxy capsules (keeps the
ACL). You won't need it for a tool capsule.
-
Inspect a real interface with the forge explain_interface { "name": "tool" }
tool (reads from home://wit/ and summarizes package/interfaces/records), or the
system capsule's list_interfaces / read_interface.
-
Canonical versus capsule-owned WIT. Canonical
@unicity-astrid/wit/... contracts are resolved at build. Custom interfaces
owned by your capsule belong in a checked-in wit/ directory and ship in the
capsule archive.
9. How a Tool Call Actually Flows (the IPC lifecycle)
Tools are a convention over bus primitives — the kernel has no notion of a "tool".
Understanding the round trip explains the boilerplate:
- The react loop decides the LLM wants tool
foo; it publishes a
ToolExecuteRequest on tool.v1.request.execute.
- The router capsule (stateless middleware) catches it, validates the tool
name (rejects anything but alphanumeric /
- / _ / : — dots are forbidden
to prevent topic injection), and forwards to tool.v1.execute.foo.
- Your capsule is subscribed to
tool.v1.execute.foo with handler
tool_execute_foo. The macro deserializes the args, runs your method, and
publishes the result on tool.v1.execute.foo.result (covered by your
tool.v1.execute.*.result publish ACL).
- The router catches the per-tool result and republishes it on the unified
tool.v1.execute.result, which react polls and matches by call_id.
The describe fan-out (how the model learns your tools exist):
- The prompt-builder subscribes
tool.v1.response.describe.*, then publishes
an empty tool.v1.request.describe.
- Your capsule's auto
tool_describe handler fires and publishes its
{ "tools": [...] } descriptor on tool.v1.response.describe.self (covered by
your tool.v1.response.describe.* publish ACL; the real source_id rides in
kernel-stamped metadata).
- The prompt-builder drains responses over a bounded ~500ms window, dedups by
tool name (first wins), and caches the schema.
Critical: tool_describe must publish, never return — a return-only
describe collects zero tools (the bug SDK 0.7.1 fixed). The #[capsule] macro does
this correctly; just depend on astrid-sdk = "0.7" (resolves to 0.7.1+).
10. The Three Topic Matchers (the conceptual footgun)
* in a topic does not mean one consistent thing — there are three matchers
with three different rules. Name this so you don't trip:
- Event delivery / route matcher (subtree). A trailing
* matches the
whole subtree at any depth ≥ prefix+1: a.b.* delivers a.b.c and
a.b.c.d. This decides which subscriptions receive a published event.
- ACL authorization (publish + subscribe). As of the current kernel, the
publish/subscribe ACL authorizes via the same subtree matcher as delivery —
a trailing
* is recursive. (tool.v1.execute.*.result authorizes
tool.v1.execute.foo.result.)
- Interceptor dispatch (strict equal-segment). When the kernel routes a
concrete event to your handler, segment count must match exactly and a mid
* matches exactly one segment. This is why each tool needs its own concrete
tool.v1.execute.<tool> subscribe row — you cannot collapse them into one
wildcard for dispatch.
- Runtime
ipc::subscribe syntactic gate. At runtime you may subscribe with
at most one *, and it must be trailing (foo.bar.*). A mid-segment or
multi-* pattern is host-rejected at runtime — even though it's legal in a
static [subscribe] ACL key. Per-capsule cap: 128 subscriptions.
Practical rules:
- Subscribe: use one trailing
* and let subtree delivery handle variable
depth. Never enumerate .*.* — redundant and runtime-illegal.
- Publish: the publish ACL is fine with the concrete patterns you declare
(
tool.v1.execute.*.result); a fixed deep front-door topic needs its own exact
publish row.
- Tool dispatch is strict — one concrete
tool.v1.execute.<tool> row per tool.
11. The Build → Install → Call Dev Loop
edit src/lib.rs / Capsule.toml
│
▼
aos capsule build # -> ./dist/<name>.capsule
│ # (plain `cargo build` works too; no --target)
▼
aos capsule install ./dist/<name>.capsule # content-addressed; replaces prior version
│
▼
aos capsule list # confirm it loaded
aos status # confirm the daemon is healthy
│
▼
ask the LLM to call the tool # verify behaviour
│
└── tools missing? -> capsule_doctor (forge tool), then re-prompt
- There is NO hot-reload — the watcher is dead code (issue #296). To iterate,
rebuild and reinstall; each install replaces the prior version.
- Logs live under
~/.aos/runtime/log/, one file per capsule. A guest panic shows
as capsule panic at src/lib.rs:NN (the SDK installs a panic hook). ERROR-level
guest logs also surface in the daemon log. Grep the per-capsule log when a tool
traps or a run loop exits.
- If
aos isn't on PATH, the standard install is at ~/.aos/bin/aos.
- If
aos capsule build is unavailable, cargo build --release works (the
.cargo/config.toml selects the target); the .wasm lands under
target/wasm32-unknown-unknown/release/.
12. Footguns (read these before you waste an hour)
- The getrandom flag.
.cargo/config.toml must carry
rustflags = ["--cfg=getrandom_backend=\"custom\""]. Without it, uuid::v4
and HashMap fail to link on wasm32-unknown-unknown. A library can't set
it for you — it must live in your crate. The #1 cause of confusing build
failures.
crate-type = ["cdylib"]. Not bin, not the default rlib.
- Put only capsule-owned contracts in
wit/. Canonical
@unicity-astrid/wit/... contracts are resolved at build; custom interfaces
must be checked in so they can ship with the capsule.
- Content-addressed install. Install with
aos capsule install ./dist/<name>.capsule. Do not hand-copy the
.wasm into ~/.aos/runtime — install records a BLAKE3 hash in meta.json, and a
capsule whose binary doesn't match (or wasn't installed this way) fails to load.
- Keep agent instruction protocols out of Capsule.toml. Host plugins may
vendor a compact trigger and the user-space Skills service may index
workspace or principal-home entries. Serve version-matched capsule guidance
through an ordinary IPC tool.
tool_describe must publish, not return — covered by depending on
astrid-sdk = "0.7" (0.7.1+). The macro does it right; don't hand-roll describe.
- Empty
[publish]/[subscribe] = silent muteness. Fail-closed means no
error you'll notice quickly — the capsule just can't talk on the bus.
env::var is per-principal; never cache it in a OnceLock/static — you'd
pin one principal's config for everyone.
recv timeout returns Ok with empty messages, not an error. Branching on
an error string for timeout is dead code.
No longer a footgun: earlier kernels had a describe fan-out that was
non-deterministically incomplete on the first prompt after boot. That race is
fixed in the current kernel — if your tools are in the manifest and the
capsule loaded, the model will see them. If they don't appear, it is almost
certainly a real manifest problem (run capsule_doctor), not a kernel race.
13. Security Mindset
Capsules run other people's prompts and handle untrusted, LLM-supplied arguments.
Three rules, always:
- Validate untrusted input. Reject path traversal in any name/path argument
(
/, \, ..). The LLM will, eventually, hand you ../../etc/passwd. The VFS
gate also rejects .., but validate at your edge too.
- Least capability. Declare the narrowest
fs_*/net/host_process scope
that works. The manifest ACL is your blast radius — and the first thing a
reviewer reads.
- Fail closed. On any doubt — missing capability, malformed input, denied
topic — refuse rather than guess. Gate sensitive actions on
message.principal.verified(). The kernel fails closed; your code should too.
14. Design Principles — Write Small Capsules
The kernel is dumb on purpose, and the consequence is: a capsule should do one
thing. The runtime composes many small capsules over the bus; it does not host a
few large ones. This isn't style — it's what the security model rewards:
- Least privilege is a function of scope. A file-reader needs only
fs_read.
Fold in writes, network, and process-spawn and its floor becomes the union of
all of them — a permanent, maximal blast radius. A prompt injection into a
single-purpose reader can at worst read files it could already read.
- Compose, don't embed. Capsules don't call each other — they publish and
subscribe. Need a capability you don't own? Publish to the capsule that owns it.
Adding behaviour = adding a capsule, never forking a monolith.
- Keep tools cohesive. The tools a capsule exports should share a domain. A
capsule whose tools have nothing in common is a bundle, not a capsule.
- Push state to where it belongs. Routing/transform capsules stay stateless;
durable state lives in session/memory/KV.
Read the [capabilities] block as a job description. If it spans unrelated
domains (files and network and process), that's the smell, not the feature.
15. The Forge Tools
The forge capsule — bundled with Unicity AOS and bridged to this Grok Build
session over MCP when AOS is running — gives you tools to
do all of the above without leaving the chat:
| Tool | Use it when |
|---|
forge_quickstart | You want the condensed build-your-first-capsule guide inline. |
scaffold_capsule { name } | You want a complete compiling skeleton as path -> content JSON to write out. |
explain_interface { name } | You need to read a WIT contract (e.g. tool, llm, session) plus a plain-English summary. |
suggest_capabilities { intent } | You describe what the capsule should do and get the exact manifest lines (incl. real LLM-provider topics). |
validate_manifest { toml } | You want your Capsule.toml linted for the common mistakes before you build. |
capsule_doctor { name } | A capsule loaded but its tools don't appear, or an import is unsatisfied — diagnose it. |
(If those tools aren't present, AOS isn't running or the forge capsule isn't
installed — every step above also works with the aos CLI and by hand,
so you are never blocked.)
Welcome to capsule authoring. Scaffold one and ship it.