Tool recipes
A tool is a class with three fields and one method. Copy it, change the body, done.
ThenaJS ships no tool package — there would be nothing to ship.
Shell
import { Tool } from "@thenajs/core";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
const run = promisify(exec);
@Tool({
name: "shell",
description: "Runs a shell command and returns its output.",
schema: z.object({ command: z.string() }),
})
export class ShellTool {
async execute({ command }: { command: string }) {
const { stdout } = await run(command);
return stdout;
}
}This gives the model command execution
Fine on your machine. Not in a service that reads third-party input — see hardening.
Fetch a web page
import { Tool } from "@thenajs/core";
import { z } from "zod";
@Tool({
name: "fetch_page",
description: "Fetches a web page and returns its text.",
schema: z.object({ url: z.string() }),
})
export class FetchPageTool {
async execute({ url }: { url: string }) {
const html = await (await fetch(url)).text();
return html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
}
}Read a file
import { Tool } from "@thenajs/core";
import { readFile } from "node:fs/promises";
import { z } from "zod";
@Tool({
name: "read_file",
description: "Reads a project file. Use before answering about code.",
schema: z.object({ path: z.string() }),
})
export class ReadFileTool {
execute({ path }: { path: string }) {
return readFile(path, "utf8");
}
}Call your own API
import { Tool } from "@thenajs/core";
import { z } from "zod";
@Tool({
name: "find_order",
description: "Looks up an order by id and returns its status.",
schema: z.object({ orderId: z.string() }),
})
export class FindOrderTool {
async execute({ orderId }: { orderId: string }) {
const order = await (await fetch(`${API}/orders/${orderId}`)).json();
return `Order ${orderId}: ${order.status}.`;
}
}Hardening for production
Everything above works. What follows is what you add when the case calls for it — each item solves a concrete problem, none of it is ceremony.
Truncate the output
The biggest hidden cost. A tool returning 4 KB twelve times sends ~50 KB of stale content on the last call, and you pay for it every turn.
const MAX = 4_000;
return text.length <= MAX ? text : `${text.slice(0, MAX)}\n… [truncated]`;Write the error the model reads
Without a try, the raw ENOENT becomes the observation — and it describes a syscall, not what to do next.
catch {
return { content: `No file at "${path}". Check the path.`, isError: true };
}This does not end the run: the model reads it and tries something else. For what it cannot fix — a database down, an expired credential — use throw new FatalToolError("…").
Pass the signal through
Without it, cancelling the run does not interrupt your tool; it only takes effect between steps.
async execute(@input() { url }: { url: string }, @context() ctx: Context) {
const res = await fetch(url, { signal: ctx.signal });
}Put a timeout on anything that can hang
await run(command, { timeout: 30_000 });Narrow the schema
Every constraint is a class of error the model cannot produce:
schema: z.object({
url: z.string().url(),
service: z.enum(["api", "worker", "web"]),
});Allowlist, if it is shell with untrusted input
const ALLOWED = new Set(["git", "ls", "cat"]);
const program = command.trim().split(/\s+/)[0] ?? "";
if (!ALLOWED.has(program)) return { content: "not allowed", isError: true };
// Without this line the list is worthless: `git status; rm -rf /` starts with `git`.
if (/[;&|`$><]/.test(command)) return { content: "no chaining", isError: true };This bounds which programs run, not what a permitted one does — cat reads any file the process can read. For genuinely untrusted input, the boundary is a container, not a regex.
Related
- Tool design — why the
descriptiondecides whether a tool gets used - Errors — observation vs
FatalToolError - Security — least privilege
