Add a sandboxed tool
Most agents never need one. Capability usually arrives through
MCP connections, the sandbox’s
bash tool, or a subagent — none of which require a
toolchain. Reach for an authored tool when you want your own compute:
sandboxed, deterministic, and with no network unless you grant it.
Create one
Section titled “Create one”maiden tool new triage-rank --dir my-agentThat writes a complete, buildable source under my-agent/tools/src/triage-rank/,
including the tool.wit contract this exact binary implements. Add --lang js
to author in JavaScript instead of Rust.
Edit the three functions it generates, then:
maiden tool build --dir my-agentThe component lands at my-agent/tools/triage-rank.wasm and the runtime picks
it up on the next run — the tool name is the file stem, so triage-rank.wasm
becomes the tool triage-rank. Pass a name (maiden tool build triage-rank) to
build just one.
Sources live under tools/src/; discovery only scans tools/*.wasm, so the
source tree is invisible to the runtime.
What you need installed
Section titled “What you need installed”| Language | Requirement | Install |
|---|---|---|
| Rust | cargo-component | cargo install cargo-component |
| JavaScript | Node.js | maiden tool build runs jco through npx — nothing to install globally |
maiden tool build tells you which one is missing rather than failing with a
compiler error.
The contract
Section titled “The contract”Every tool implements one world (wit/tool.wit):
world sandboxed-tool { import host; // log + gated http-fetch
export describe: func() -> string; // human description export schema: func() -> string; // JSON Schema for the input export execute: func(params-json: string) -> result<string, string>;}describeandschemaare read once so maiden can tell the model what the tool is and what it accepts.executereceives the model’s arguments as a JSON string and returns a JSON result string.
Returning an error
Section titled “Returning an error”The error case of result<string, string> is fed back to the model, so write
errors it can act on — a missing field, a bad value — rather than a stack trace.
In Rust, return Err:
fn execute(params_json: String) -> Result<String, String> { let v: serde_json::Value = serde_json::from_str(¶ms_json).map_err(|e| format!("bad json: {e}"))?; let input = v.get("input").and_then(|x| x.as_str()) .ok_or_else(|| "missing 'input'".to_string())?; Ok(serde_json::json!({ "echoed": input }).to_string())}In JavaScript, throw an Error carrying a payload property — that is what
the componentizer reads. A bare string, or an Error without payload, traps
the instance instead, and a trap is something the model cannot recover from:
function fail(message) { const e = new Error(message); e.payload = message; return e;}
export function execute(paramsJson) { const params = JSON.parse(paramsJson); if (typeof params.input !== 'string') { throw fail("missing 'input'"); } return JSON.stringify({ echoed: params.input });}The scaffold generates this shape for you in both languages.
Size tradeoff
Section titled “Size tradeoff”The author language affects component size:
| Author | Component size |
|---|---|
| Rust (release) | ~148 KB |
| Rust (debug) | ~3.4 MB |
| JavaScript | ~12 MB (embeds a JS engine) |
JavaScript tools are convenient but bundle a JavaScript engine into every component. For many small tools, Rust keeps things lean.
Wire the policy
Section titled “Wire the policy”Tools run under a default policy (timeout_ms = 5000, memory_mb = 64).
Override it per tool in agent.toml:
[tools.my-tool]timeout_ms = 15000memory_mb = 128http_allow = ["api.example.com"]secrets = ["MY_TOKEN"]http_allow and secrets define what the tool is allowed to reach and which
credentials it may name. Both default to empty: a tool with no policy can make
no request at all.
Reach an API
Section titled “Reach an API”http-fetch is the only door out. Name the credential you need — never its
value — and the host attaches it:
let resp = host::http_fetch(&host::HttpReq { url: "https://api.example.com/v1/items".to_string(), method: Some("POST".to_string()), headers: vec![("content-type".to_string(), "application/json".to_string())], body: Some(r#"{"name":"widget"}"#.to_string()), // Must appear in this tool's `secrets`. The host resolves it from // MAIDEN_SECRET_MY_TOKEN and sends it as `Authorization: Bearer …`. credential_name: Some("MY_TOKEN".to_string()),})?;
if resp.status >= 400 { return Err(format!("api said {}: {}", resp.status, resp.body));}In JavaScript the same call is httpFetch with camelCased fields and headers
as pairs:
import { httpFetch } from 'maiden:tool/host@0.1.0';
const resp = httpFetch({ url: 'https://api.example.com/v1/items', method: 'POST', headers: [['content-type', 'application/json']], body: JSON.stringify({ name: 'widget' }), credentialName: 'MY_TOKEN',});A refused request throws; read e.payload for the host’s reason.
method defaults to GET. The response comes back whole — non-2xx included, so
the tool decides what a failure means. Four things the host will refuse before
anything leaves the process: a host that isn’t in http_allow, a plain-http
URL (loopback excepted), a credential the tool didn’t declare, and a redirect —
3xx responses are returned, never followed. The request also spends the tool’s
timeout_ms. See the security model for why.
Verify
Section titled “Verify”Run your agent offline to confirm the tool loads and the model can call it:
maiden run my-agent "use my-tool on ..." --mock