# tooldance — Manager Guide (for AI agents) > The service and its docs live at **** — start there for the > latest reference, the CLI, and the [caller guide](./caller.AGENTS.md). You are managing a **tooldance** service: a self-hosted runtime that exposes user-defined TypeScript/JavaScript functions as HTTP RPC endpoints. As a manager you create, inspect, update, and delete *tools*, and you mint/revoke the *caller keys* that let other agents invoke them. Everything is done over the HTTP API with the **manage token**. There is no separate admin CLI — the management surface is `curl` (or any HTTP client). The `tooldance.sh` CLI is for *callers only* (see `caller.AGENTS.md`). ## Setup All management calls authenticate with the manage token: ```bash export TOOLDANCE_HOST=https://your-tooldance.example.com # base URL, no trailing slash export TOOLDANCE_MANAGE_KEY=your-manage-token ``` > **The manage token is remote code execution on the host.** Anyone holding it > can upload a tool that declares its own permissions and run it. Treat it like > a root credential: never log it, never commit it, never hand it to a caller. > Callers get scoped *caller keys* that you mint (see "Caller keys" below). Quick connectivity check (no auth required): ```bash curl -fsS "$TOOLDANCE_HOST/health" # -> {"ok":true} ``` ## Tool model — what a tool is A tool is a single file that **default-exports one async function**. The function receives the parsed JSON request body as its only argument and returns a JSON-serializable value: ```ts export default async function (args: { city: string; units?: string }) { // ... do work ... return { tempC: 21, conditions: "clear" }; } ``` Each call runs in a **fresh, sandboxed Deno subprocess** with only the permissions you granted at upload time. The return value is written out as JSON and sent back to the caller wrapped as `{ "ok": true, "result": }`. Hard rules the runtime enforces: - **Name**: must match `^[a-zA-Z][a-zA-Z0-9_]{0,62}$` (letter first; letters, digits, underscore; ≤63 chars). Names are **case-insensitively unique** — `Weather` and `weather` cannot coexist. The name `keys` is **reserved**. - **Return value** must be JSON-serializable and ≤ 10 MiB. A tool that returns `undefined`/throws/exits non-zero surfaces as an error to the caller. - **Source size** ≤ 256 KiB. **Description** ≤ 1024 chars. **timeout_ms** is an integer in `[100, 600000]` (default 30000). - The function must be the **default export**. Imports are allowed (Deno resolves `https://`/`jsr:` specifiers at run time) but every dependency fetch needs matching `allow-net` permission, and network fetches add latency to the cold call. ## Create or update a tool — `PUT /_manage/:name` `PUT` is an upsert: it creates the tool if new, replaces it if it exists (`created_at` is preserved across updates). Body fields: | field | required | notes | |---------------|----------|-------| | `code` | yes | non-empty string, the full tool source (≤256 KiB) | | `description` | no | string ≤1024 chars — **the only docs callers see** | | `timeout_ms` | no | integer 100–600000, default 30000 | | `permissions` | no | object, default `{}` (no access) | ```bash curl -fsS -X PUT "$TOOLDANCE_HOST/_manage/weather" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" \ -H "Content-Type: application/json" \ -d '{ "code": "export default async ({ city }) => {\n const r = await fetch(`https://api.example.com/wx?q=${encodeURIComponent(city)}`);\n if (!r.ok) throw new Error(`upstream ${r.status}`);\n const { temp_c, summary } = await r.json();\n return { city, tempC: temp_c, conditions: summary };\n}", "description": "Current weather for a city.\nArgs: { city: string (required) — city name, e.g. \"Paris\" }.\nReturns: { city, tempC: number, conditions: string }.", "timeout_ms": 10000, "permissions": { "allow-net": ["api.example.com:443"] } }' # -> { "ok": true, "tool": "weather", "created": true } ``` `created: true` means it was newly created; `false` means you replaced an existing tool. Updates take effect on the **next call** — there is no restart. ### Documenting arguments (do this — there is no schema field) tooldance has **no machine-readable argument schema**. The *only* thing a caller discovers (via `GET /readme`) is the `description` string. So **hand-document the arguments inside the description.** A good description states, in plain text: 1. One line on what the tool does. 2. **Args**: each field, its type, whether required, and an example value. 3. **Returns**: the shape of the result. ``` Geocode a street address to coordinates. Args: { address: string (required) — full street address; country?: string — ISO-3166 alpha-2, defaults to "US" } Returns: { lat: number, lng: number, formatted: string } ``` Without this, a calling agent is flying blind — it sees the name and one sentence and has to guess the JSON body. Treat the description as the tool's API docs. ## Crafting a good tool - **Validate inputs at the top.** `args` is whatever JSON the caller POSTed — it may be missing fields or the wrong type. Throw a clear `Error("...")` on bad input; the message is returned to the caller (stderr is scrubbed of file paths and truncated to ~4 KB, so put the useful part first). - **Return structured objects**, not bare strings, so callers can pick fields. - **Request the narrowest permissions** that work (next section). Default `{}` means no net/env/read/write — a pure compute tool needs nothing. - **Set a realistic `timeout_ms`.** Network tools: a few seconds. Pure compute: keep it low so a runaway loop is killed fast. The ceiling is 600000 (10 min). - **Keep output bounded.** Results cap at 10 MiB; stdout at 16 MiB. Don't return giant blobs — paginate or summarize. - **No secrets in source.** The code is readable via `GET /_manage/:name`. Pass secrets via `allow-env` (below), not string literals. - **Idempotency / side effects.** The runtime may abort a call if the client disconnects or the timeout fires. Don't assume a tool runs exactly once. ## Permissions — the `permissions` object Permissions are an **upper bound chosen per tool**, expressed as four optional arrays. Anything you don't list is denied. There is **no `*` wildcard** in any entry, and unknown keys are rejected. | key | entry format | example | |---------------|--------------|---------| | `allow-net` | `host` or `host:port` (IPv4/hostname; **no IPv6**) | `"api.example.com:443"` | | `allow-env` | a valid env var name | `"WEATHER_API_KEY"` | | `allow-read` | an **absolute** path (`/...`) | `"/data/reference"` | | `allow-write` | an **absolute** path (`/...`) | `"/tmp/scratch"` | Each entry must be a non-empty string with no whitespace, comma, `=`, `--`, or `*`. ```json "permissions": { "allow-net": ["api.example.com:443", "ipinfo.io:443"], "allow-env": ["WEATHER_API_KEY"], "allow-read": ["/data/reference"] } ``` Important behaviors: - **`allow-env` is the secret-injection escape hatch.** The sandbox normally strips secret-looking env vars (`*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `AWS_*`, `ANTHROPIC_*`, `DATABASE_URL`, …) from the child. But a var you **explicitly list in `allow-env` is injected even if its name looks secret.** That is how you give one tool one API key without exposing it to others. - **Per-tool permissions are bounded by the host process.** The host runs with whatever Deno grant it was started with (default `--allow-all`). A tool can never exceed the host's own grant, regardless of what it declares. - **Don't grant read on `/tmp` or `TMPDIR`** — per-call args/results live there transiently and you'd leak other calls' data. - Network is **allowlisted by host**: `["api.example.com:443"]` permits exactly that host:port. Omitting the port allows any port on that host. ## Inspect tools List every tool (metadata only — no source): ```bash curl -fsS "$TOOLDANCE_HOST/_manage" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" # -> { "ok": true, "tools": { "weather": { description, timeout_ms, permissions, updated_at }, ... } } ``` Get one tool's full config **and source code**: ```bash curl -fsS "$TOOLDANCE_HOST/_manage/weather" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" # -> { "ok": true, "tool": "weather", "config": {...}, "code": "export default ..." } ``` Use this before an update to read the current source, then `PUT` the modified version back. ## Delete a tool — `DELETE /_manage/:name` ```bash curl -fsS -X DELETE "$TOOLDANCE_HOST/_manage/weather" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" # -> { "ok": true, "tool": "weather" } (404 if it didn't exist) ``` Deletion removes the source file and the manifest entry atomically. Caller keys scoped to that tool keep the name in their allowlist harmlessly — the tool just 404s until/unless you recreate it. This is greenfield: prefer replacing an old tool outright over leaving dead variants around. ## Caller keys — minting access for calling agents Callers never use the manage token. You mint **caller keys**: each has a unique **label** and is **scoped to a list of tool names** (`["*"]` for all tools). The server generates the secret, stores only its SHA-256 hash, and returns the plaintext token **exactly once** — capture it, you cannot recover it later. **Create** — `POST /_manage/keys`: ```bash curl -fsS -X POST "$TOOLDANCE_HOST/_manage/keys" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" \ -H "Content-Type: application/json" \ -d '{ "label": "research-agent", "tools": ["weather", "geocode"] }' # -> { "ok": true, "label": "research-agent", "tools": [...], "created_at": "...", # "token": "" } ``` - `label` must match `^[a-zA-Z0-9-]+$` and be unique (reusing one returns `409`). The label is the handle you list/revoke by. - `tools` is a **non-empty** array of tool names, or `["*"]` for everything. Scope to the minimum the caller needs. A caller hitting a tool outside its scope gets `403`. - Hand the returned `token` to the calling agent as its `TOOLDANCE_KEY`. **List** keys (labels, scopes, created_at — never secrets): ```bash curl -fsS "$TOOLDANCE_HOST/_manage/keys" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" # -> { "ok": true, "keys": [ { label, tools, created_at }, ... ] } ``` **Revoke** by label (takes effect immediately; `404` if unknown): ```bash curl -fsS -X DELETE "$TOOLDANCE_HOST/_manage/keys/research-agent" \ -H "Authorization: Bearer $TOOLDANCE_MANAGE_KEY" # -> { "ok": true, "label": "research-agent" } ``` ### Updating a key's scope There is no "edit key" endpoint — keys are immutable once minted. To change what a caller can reach, **revoke the old label and create a new one** (the caller gets a fresh token). Plan labels accordingly (e.g. `research-agent-v2`). ### Rotating a caller token Same pattern: create a new key (new token), hand it over, then revoke the old label. Both can be live briefly during the cutover. ## Error responses All errors come back as `{ "ok": false, "error": "" }` with an HTTP status: | status | meaning | |--------|---------| | 400 | validation failed (bad name, oversized body, bad permissions, bad JSON) | | 401 | missing/wrong manage token | | 403 | (caller path) key not scoped to that tool | | 404 | tool or key label not found | | 409 | name/label collision (case-insensitive tool name, or duplicate label) | | 500 | tool threw or produced invalid output | | 503 | server/per-caller concurrency limit hit — retry with backoff | | 504 | tool exceeded its `timeout_ms` | Note `curl -fsS` exits non-zero on 4xx/5xx and may suppress the body; drop `-f` (or add `-w '%{http_code}'`) when you need to read the error message. ## Quick reference | action | request | |--------|---------| | health | `GET /health` (no auth) | | list tools | `GET /_manage` | | get tool + code | `GET /_manage/:name` | | create/update tool | `PUT /_manage/:name` | | delete tool | `DELETE /_manage/:name` | | create caller key | `POST /_manage/keys` | | list caller keys | `GET /_manage/keys` | | revoke caller key | `DELETE /_manage/keys/:label` | All `/_manage/*` routes require `Authorization: Bearer $TOOLDANCE_MANAGE_KEY`.