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

Add a Tool

On this page

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. To ship a tool outside the engine instead, as a plugin with its own namespace, see Tools → Plugins; the tool file itself is written the same way.

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 and dynamic-imports every *.tool.(ts|js) file. Adding the file is the only registration step.

Step 2: Export the tool definition

A tool file has exactly one job: default-export the result of defineTool, from the plugin SDK.

// SPDX-License-Identifier: AGPL-3.0-or-later import { z } from "zod"; import { defineTool } from "@polyant-ai/plugin-sdk"; export default defineTool({ 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" } }, ], parameters: z.object({ query: z.string().describe("Search query to send to the upstream API."), }), execute: async (input, ctx) => { // ... see Step 5 }, });

This is the only shape the loader recognises. A file whose default export has no inputSchema - anything that is not a defineTool result - is skipped with a console warning, and the tool silently does not exist. There is no compatibility path and no self-registering call: if a tool does not appear, check that the file default-exports defineTool(...) before looking anywhere else.

The name field is the only identifier: it is the LLM-visible tool name and the instance_tools enablement row alike. The file name and the export are not part of it - the export is a default one and carries no name at all.

Step 3: The schema is static, and ctx arrives at call time

parameters is a plain Zod schema evaluated once, at module load, and serialized to JSON Schema there. It may not depend on the runtime context - there is no per-turn factory to build it in.

The context is the second argument to execute instead. 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. Scoped to what this tool declared: only the keys listed in requiredSecrets are present, and reading any other returns undefined. Least privilege is enforced, not merely advised - a secret you forgot to declare looks exactly like a secret the operator forgot to set.
  • ctx.audit - scoped audit logger. It has a single method: ctx.audit.log({ action, details?, success?, error?, durationMs?, output? }).
  • ctx.conversationId - useful for correlation.
  • ctx.attachments - files uploaded with the current user message.
  • ctx.state - shared per-conversation key/value state, tool to tool, in cleartext.
  • ctx.oauth - engine-brokered OAuth tokens and refresh, for tools that declare a provider.
  • ctx.conversation - read-only accessor for recent conversation history.
  • ctx.apiKeys / ctx.provider - for tools that make their own model calls. With managed services ctx.apiKeys holds the agent’s chat connection only, and a tool that declares a provider key such as openai_api_key in requiredSecrets never receives it: provider keys are held by provider connections, and such a tool is treated as missing its secret.
  • ctx.artifacts - a short-lived handoff of generated bytes between tools of the same conversation, for example one plugin renders a PDF and another uploads it. await ctx.artifacts.put({ buffer, filename, mime }, ttlMs?) stores the bytes and returns a handle to give the model; await ctx.artifacts.take(handle) returns them once and then null. A handle is bound to its agent and conversation and expires after a short engine-capped lifetime, and take answers null without saying whether the handle was unknown, taken, expired or foreign. put throws when the engine refuses the payload, for example because it is too large. Always await both: in dev mode they are a round trip to the engine, which needs plugin SDK 1.9.0 in the dev client.
  • ctx.knowledge - the agent’s knowledge base, when the agent grants it (managed services). Absent entirely when it does not, which is the SDK’s “no access” signal. The open-source build never mounts it.

ctx.knowledge closes over the turn’s agent: no method takes an instance, so tool code cannot reach another agent’s base. How much it grants is a level, not a set of flags, because the three are nested - manage contains write contains read - so “writes but cannot read” is not configurable and deleting documents is not conceded in the same gesture as searching them.

A new agent starts at read, deliberately not at write: the accessor is mounted on every tool of the turn, third-party plugin tools included, so a blanket write would hand every already-installed plugin the run of the knowledge base. Raising the level is an operator’s decision, on the agent’s Knowledge tab (Tool access: Read only, Read and write, Full management). write lets a tool create and update only the documents it wrote itself: writing to a document uploaded from the panel answers DOCUMENT_NOT_OWNED, and only manage restores that. An agent with knowledge disabled, or carrying a level the build does not recognise, grants nothing.

Writes are attributed from the tool’s canonical name, never declared by the tool: a namespaced plugin tool (<namespace>:<tool>) writes as that plugin, anything else writes as the agent. A tool that could name its own writer could claim another plugin’s documents. The audit entry is written by the accessor rather than by each caller, so a plugin cannot write to the knowledge base without leaving a trace.

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.

  • No format keyword from this set: uri, email, uuid, date-time, date, time, ipv4, ipv6. In Zod that rules out .url(), .email(), .uuid() and - the one that catches people out - .datetime(). These 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().

Give every field a .describe("...") as well. That one is a convention rather than a lint rule - the description is part of the LLM-visible schema, and a field without one is a field the model has to guess at.

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

Return a result object rather than throwing. The registry does catch, log and rethrow, and the AI SDK turns that into a tool error - but a thrown exception reaches the model as an opaque failure, while a returned { success: false, error } tells it what went wrong and what to try instead:

execute: async (input, ctx) => { 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", label: "Acme API key", description: "From acme.com / settings / API." }, { key: "my_region", type: "select", choices: ["eu", "us"], optional: true }, ],

Every key is a field someone fills in the tool’s panel on the agent’s Tools tab, under Parameters. label is the field’s title and description the sentence under it; both are optional, and without them the panel humanizes the key and says the author gave no description. When several tools declare the same key, the field is shown once and names every tool that asks for it.

type: "text" is a free-text field; type: "select" exposes a dropdown of choices. sensitive decides whether the value is a credential: a text field is one by default, and a select or a field declared sensitive: false (a base URL, say) is readable, so the panel shows its current value and an agent export carries it. A key that any tool declares sensitive is a credential for all of them. optional: true lets the field be empty without the tool being withheld - the conditional logic stays inside execute(). A tool missing a required value is not served to the model at all.

Step 7: Surface input examples (optional)

inputExamples is an Array<{ label, input }>. Each one is appended verbatim to the LLM-visible tool description, so they double as few-shot hints. They are not validated against the schema - an example that does not match parameters is passed to the model unchanged, and nothing warns you. Keep them in step with the schema by hand.

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 catalogue the Tools tab’s picker browses.
  2. Open your agent → Tools → Enable tools, find the tool (search, or browse by category), select it and confirm with Enable 1.
  3. If it declares a required secret, its panel opens on the Parameters. Type the values and click Save parameters. Through the API, the same values go to PUT /api/instances/:slug/secrets.
  4. Click the page’s Save to confirm the enablement.

Verification

  • GET /api/tools lists your tool with the expected description, category, and requiredSecrets.
  • The agent’s Tools tab lists the tool. If a required value is missing, the row says Missing … and the tool is not served until you fill it in.
  • 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