Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.
How-toAdd a Custom Tool

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 dev working
  • 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.ts

There 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; call ctx.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. Use z.string().describe("...") and validate inside execute().
  • No bare .optional(). The LLM frequently passes null for missing fields, which trips Zod’s optional. Use .nullable() (or .nullable().optional() if you also need it absent) and apply the default in execute().
  • No z.record(z.unknown()) — providers reject open-ended objects. Use z.record(z.string()) for string maps, or accept a stringified JSON parameter and parse it inside execute().

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:

  1. The tool appears in GET /api/tools and in the admin Tools catalog.
  2. Open your instance → Tools tab → toggle the new tool on.
  3. If it declares requiredSecrets, populate them under SettingsSecrets (PUT /api/instances/:slug/secrets).

Verification

  • GET /api/tools lists your tool with the expected description, category, and requiredSecrets.
  • 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-mode to confirm the schema passes strict-mode checks.

See also

Last updated on