Skip to content
Back to skills

Forge

BSecurity

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.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 19, 2026
ai-agentsrustgobashreactgitapibackendsecurity

Works with

  • cli
  • api
  • mcp

Security analysis

B75/100
  • criticalAccesses sensitive system or user directories

Pro shows the line behind each finding and how to fix it

Scanned September 19, 2026

npx -y skills add unicity-aos/oracles --skill forge --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Forge?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Forge
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/unicity-aos-forge-oracles/badge)](https://www.skillsdirectory.com/skills/unicity-aos-forge-oracles)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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`)

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:

```rust
fs::exists(path) -> Result<bool>
fs::read(path) -> Result<Vec<u8>>
fs::read_to_string(path) -> Result<String>
fs::write(path, contents: &[u8]) -> Result<()>      // truncate-or-create; 10 MB/call cap
fs::create_dir(path) -> Result<()>                  // strict: fails if it exists
fs::create_dir_all(path) -> Result<()>              // idempotent
fs::remove_file(path) -> Result<()>
fs::read_dir(path) -> Result<ReadDir>               // iterator of DirEntry (.file_name()); 4096-entry cap
fs::metadata(path) -> Result<Metadata>              // .is_file(), .is_dir(), .len(), .modified()
```

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)

```rust
kv::get_json::<T>(key) -> Result<T>            // T: DeserializeOwned
kv::get_json_opt::<T>(key) -> Result<Option<T>>
kv::set_json::<T>(key, &value) -> Result<()>   // T: Serialize
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>   // compare-and-swap
// Versioned + borsh variants also exist.
```

### `http` — outbound HTTP (needs `net` capability)

```rust
let resp = http::send(&http::Request::get("https://api.example.com")
    .header("authorization", "Bearer …")
    .json(&body)?)?;            // .post/.put/.delete; .body/.body_bytes too
resp.status() -> u16; resp.is_success() -> bool;
resp.text() -> Result<&str>; resp.json::<T>() -> Result<T>; resp.bytes() -> &[u8]
// http::stream_start(&req) -> HttpStream for chunked/SSE responses (.read_chunk()).
```

### `ipc` — the event bus (publish/subscribe; the heart of capsule comms)

```rust
ipc::publish(topic, payload: &str) -> Result<()>
ipc::publish_json::<T>(topic, &payload) -> Result<()>           // T: Serialize
ipc::subscribe(topic_pattern) -> Result<Subscription>          // trailing-* only (see §10)
sub.recv(timeout_ms) -> Result<PollResult>                     // empty messages = timeout
sub.poll() -> Result<PollResult>                               // non-blocking
ipc::request_response::<Req, Resp>(req_topic, resp_namespace, &req, timeout_ms) -> Result<Resp>
// publish_as / publish_json_as exist but are UPLINK-ONLY (need uplink = true).
```

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/`)

```rust
log::trace(msg); log::debug(msg); log::info(msg); log::warn(msg); log::error(msg);
// arg is `impl Display`. ERROR-level guest logs also surface in the daemon log.
```

### `runtime`

```rust
runtime::signal_ready() -> Result<()>          // run-loop capsules call this when initialized
runtime::caller() -> Result<CallerContext>     // who invoked this handler
runtime::random_bytes(len) -> Result<Vec<u8>>  // host entropy (OsRng)
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]`

```toml
[package]
name = "my-http-capsule"       # required; lowercase ASCII alphanumeric + hyphens
version = "0.1.0"             # required; three-part numeric
description = "HTTP fetch tool"
authors = ["Name <e@mail>"]
astrid-version = ">=0.7.0"     # semver req against the host runtime
license = "MIT OR Apache-2.0"
repository = "https://github.com/…"
keywords = ["http", "fetch"]
categories = ["networking"]
publish = true                # set false to block registry publication
```

### `[[component]]`

One per WASM binary (a capsule usually has exactly one).

```toml
[[component]]
id = "http-tools"
file = "astrid_capsule_http.wasm"  # path relative to Capsule.toml; crate name, hyphens->underscores
type = "executable"               # "executable" (default) or "library"
# hash = "sha256:…"               # optional; checked against meta.json at load
```

### `[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:

```toml
[publish]
"tool.v1.execute.*.result" = "@unicity-astrid/wit/types/tool-call-result"   # short form
"tool.v1.response.describe.*" = { wit = "@unicity-astrid/wit/tool/describe-response" }  # long

[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:

```toml
# Flat (cargo-like):
[imports]
"astrid:llm" = "^1.0"
"astrid:kv"  = { version = "^1.0", optional = true }   # optional: boot even with no provider

[exports]
"astrid:llm" = "1.0.0"

# Nested (equivalent):
[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

```toml
[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. |

```toml
# Worked example — a capsule that reads home, fetches one API, and runs git:
[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:

1. The **react** loop decides the LLM wants tool `foo`; it publishes a
   `ToolExecuteRequest` on `tool.v1.request.execute`.
2. 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`.
3. **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).
4. 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):

1. The **prompt-builder** subscribes `tool.v1.response.describe.*`, then publishes
   an empty `tool.v1.request.describe`.
2. **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).
3. 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:

1. **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.
2. **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`.)
3. **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.
4. **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)

1. **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.
2. **`crate-type = ["cdylib"]`.** Not `bin`, not the default `rlib`.
3. **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.
4. **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.
5. **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.
6. **`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.
7. **Empty `[publish]`/`[subscribe]` = silent muteness.** Fail-closed means no
   error you'll notice quickly — the capsule just can't talk on the bus.
8. **`env::var` is per-principal; never cache it** in a `OnceLock`/static — you'd
   pin one principal's config for everyone.
9. **`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:

1. **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.
2. **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.
3. **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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.