Add a Tool
Tools are first-class capabilities the agent can call — a function with a Zod schema, a runtime context, and an executor. They self-register at engine boot, so adding one is a single new file under packages/engine/src/agents/tools/. This guide walks through the file structure, the schema rules enforced by the strict-mode test suite, and the conventions for secrets and harness tools.
Prerequisites
- A local Polyant checkout with
npm run devworking - TypeScript familiarity, basic Zod
- The engine running under
tsx watch(it auto-reloads new tool files)
Step 1: Create the tool file
Filenames are kebab-case and end with .tool.ts:
packages/engine/src/agents/tools/my-tool.tool.tsThere is no barrel file — the registry scans this directory at boot (registry.ts, loadAllTools() around line 260) and dynamic-imports every *.tool.(ts|js) file. Adding the file is the only registration step.
Step 2: Register the tool
Import registerTool from the local registry and call it at module top-level:
// SPDX-License-Identifier: AGPL-3.0-or-later
import { z } from "zod";
import { registerTool } from "./registry.js";
registerTool({
name: "myTool", // camelCase. snake_case is reserved for harness/room tools.
description: "Short, action-oriented description shown to the LLM.",
category: "general",
requiredSecrets: [
{ key: "my_api_key", type: "text", description: "API key from acme.com" },
],
inputExamples: [
{ label: "minimal", input: { query: "hello" } },
],
create: (ctx) => ({
parameters: z.object({
query: z.string().describe("Search query to send to the upstream API."),
}),
execute: async (input) => {
// ... see Step 5
},
}),
});The tool name lives in three places: the registered name (here myTool), the LLM-visible tool name (same), and the instance_tools enablement row (same). Keep them aligned — the name is the only identifier.
Step 3: Define the runtime factory
create(ctx: ToolContext) => { parameters, execute } is called once per pipeline turn. ctx carries the runtime essentials:
ctx.instanceId— the instance slug (not the UUID). Pass this to stores that key by slug; resolve to UUID first when querying tables that FK on instance UUID.ctx.secrets— decrypted per-instance secrets, keyed by lowercase snake_case.ctx.audit— scoped audit logger; callctx.audit.info({...})for trace entries.ctx.conversationId— useful for correlation.ctx.attachments— files uploaded with the current user message.
Step 4: Write a strict-mode-safe Zod schema
The strict-mode.test.ts suite enforces these rules across every registered tool. Violations fail the test, not the boot.
- Every field MUST have
.describe("..."). The description is part of the LLM-visible schema. - No
.url(),.email(),.uuid()— these refinements are not surfaced reliably to all providers. Usez.string().describe("...")and validate insideexecute(). - No bare
.optional(). The LLM frequently passesnullfor missing fields, which trips Zod’s optional. Use.nullable()(or.nullable().optional()if you also need it absent) and apply the default inexecute(). - No
z.record(z.unknown())— providers reject open-ended objects. Usez.record(z.string())for string maps, or accept a stringified JSON parameter and parse it insideexecute().
Example of a compliant schema:
parameters: z.object({
query: z.string().describe("What to search for."),
limit: z.number().int().nullable().describe("Max results, defaults to 10."),
filters: z.record(z.string()).nullable().describe("Optional key/value filters."),
})Step 5: Implement execute with explicit error handling
execute must never throw — the supervisor does not catch exceptions cleanly. Return a result object instead:
execute: async (input) => {
const apiKey = ctx.secrets?.my_api_key;
if (!apiKey) {
return { success: false, error: "Missing secret 'my_api_key'." };
}
try {
const res = await fetch(`https://api.acme.com/q?q=${encodeURIComponent(input.query)}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
return { success: false, error: `Upstream ${res.status}` };
}
const data = await res.json();
return { success: true, result: data };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},Step 6: Declare required secrets
Use the typed RequiredSecretSpec shape — names are lowercase snake_case (e.g. hubspot_api_key, openai_api_key):
requiredSecrets: [
{ key: "my_api_key", type: "text", description: "From acme.com / settings / API." },
{ key: "my_region", type: "select", choices: ["eu", "us"], optional: true },
],type: "text" is a free-text/masked field; type: "select" exposes a dropdown of choices in the admin UI. optional: true lets the field be empty without flagging the tool as misconfigured — the conditional logic stays inside execute().
Step 7: Surface input examples (optional)
inputExamples is an Array<{ label, input }>. Each input is validated against parameters.partial() at boot — if an example is malformed the engine logs a warning. Examples are concatenated into the LLM-visible tool description, so they double as few-shot hints.
Step 8: Harness tools
If your tool is part of the supervisor’s harness (room cycle, supervisor-internal orchestration) and should bypass per-instance enablement, set harness: true. Harness tools are not listed in the admin UI and are not stored in instance_tools; they are injected when the supervisor runs with a matching includeHarness set (e.g. "room"). Convention: harness room tools use snake_case names (mark_events_completed), regular tools use camelCase.
Step 9: Reload and enable
npm run dev keeps tsx watch running and will reload on file save. If the engine does not pick up the new tool, save packages/engine/src/index.ts once to force a respawn.
After boot:
- The tool appears in
GET /api/toolsand in the admin Tools catalog. - Open your instance → Tools tab → toggle the new tool on.
- If it declares
requiredSecrets, populate them under Settings → Secrets (PUT /api/instances/:slug/secrets).
Verification
GET /api/toolslists your tool with the expecteddescription,category, andrequiredSecrets.- The instance Tools tab shows the toggle, with a red badge if any required secret is missing.
- In the Playground, ask the agent something that should hit the tool and watch the tool-call entry in the conversation trace.
- Run
npm test -w @polyant/engine -- strict-modeto confirm the schema passes strict-mode checks.