Skip to content

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.

Terminal window
maiden tool new triage-rank --dir my-agent

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

Terminal window
maiden tool build --dir my-agent

The 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.

LanguageRequirementInstall
Rustcargo-componentcargo install cargo-component
JavaScriptNode.jsmaiden 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.

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>;
}
  • describe and schema are read once so maiden can tell the model what the tool is and what it accepts.
  • execute receives the model’s arguments as a JSON string and returns a JSON result string.

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(&params_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.

The author language affects component size:

AuthorComponent 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.

Tools run under a default policy (timeout_ms = 5000, memory_mb = 64). Override it per tool in agent.toml:

[tools.my-tool]
timeout_ms = 15000
memory_mb = 128
http_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.

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.

Run your agent offline to confirm the tool loads and the model can call it:

Terminal window
maiden run my-agent "use my-tool on ..." --mock