Add a Tool
On this page
- Prerequisites
- Step 1: Create the tool file
- Step 2: Export the tool definition
- Step 3: The schema is static, and
ctxarrives at call time - Step 4: Write a strict-mode-safe Zod schema
- Step 5: Implement
executewith explicit error handling - Step 6: Declare required secrets
- Step 7: Surface input examples (optional)
- Step 8: Harness tools
- Step 9: Reload and enable
- Verification
- See also
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 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 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 adefineToolresult - 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-exportsdefineTool(...)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 inrequiredSecretsare present, and reading any other returnsundefined. 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 servicesctx.apiKeysholds the agent’s chat connection only, and a tool that declares a provider key such asopenai_api_keyinrequiredSecretsnever 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 thennull. A handle is bound to its agent and conversation and expires after a short engine-capped lifetime, andtakeanswersnullwithout saying whether the handle was unknown, taken, expired or foreign.putthrows when the engine refuses the payload, for example because it is too large. Alwaysawaitboth: 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
formatkeyword 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; 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().
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:
- The tool appears in
GET /api/toolsand in the catalogue the Tools tab’s picker browses. - Open your agent → Tools → Enable tools, find the tool (search, or browse by category), select it and confirm with Enable 1.
- 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. - Click the page’s Save to confirm the enablement.
Verification
GET /api/toolslists your tool with the expecteddescription,category, andrequiredSecrets.- 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-modeto confirm the schema passes strict-mode checks.