Skip to Content
Polyant is open source under AGPL-3.0 — star us on GitHub.

Tools

On this page

A tool is an atomic action the LLM can take during a turn: search memory, send a Slack message, read a knowledge document, schedule a future task, spawn a sub-agent. Tools have typed inputs (validated by Zod), typed outputs, a stable name, and a description the LLM reads when deciding whether to call them. Polyant tools self-register at boot - adding a new tool is one file.

This page covers the registry, the ToolContext passed to every tool, the strict-mode constraints on Zod schemas, the per-instance enablement model, harness tools, and the conventions for shipping a reliable tool.

Self-registering registry

Every tool lives in packages/engine/src/agents/tools/ as a file ending in *.tool.ts. At boot, loadAllTools() scans the directory and dynamic-imports every match, collecting each file’s default export - which must be a defineTool(...) result - into an in-memory registry keyed by tool name. A file with any other default export is skipped with a warning rather than failing the boot.

The supervisor queries the registry at request time, filters it by what the active instance has enabled (more on that below), and hands the filtered set to the Vercel AI SDK as a Record<string, Tool>. There are no hardcoded imports, no central tool list to keep in sync.

After all imports complete, the registry prunes any tool whose requiredEnv vars (legacy, process-env-based) are missing, logging a warning. Most tools today rely on per-instance secrets instead - see below.

Tool definition API

import { defineTool } from "@polyant-ai/plugin-sdk"; export default defineTool({ name: "myTool", description: "What this tool does, in one sentence. The LLM reads this.", category: "integration", requiredSecrets: [ { key: "openai_api_key", type: "text", label: "OpenAI API key", optional: false }, ], inputExamples: [ { label: "Common use case", input: { foo: "bar" } }, ], parameters: z.object({ foo: z.string() }), execute: async (params, ctx) => { try { const result = await doTheThing(params.foo, ctx.secrets?.openai_api_key); return { success: true, result }; } catch (err) { return { success: false, error: errMsg(err) }; } }, });

The schema is static and the context arrives at call time. parameters is evaluated once, at module load, and serialized to JSON Schema there - so it cannot depend on the runtime context, and there is no per-request factory. What varies per request is the second argument to execute: a fresh ToolContext, which is how the same tool talks to agent A’s Slack workspace and agent B’s in parallel without leaking state.

Serializing the schema at load time is what makes a third-party plugin safe: the engine receives plain JSON Schema data rather than a live Zod object crossing a package boundary, so a plugin resolving its own copy of Zod is harmless. Runtime validation of the model’s arguments is done with Ajv against that JSON Schema; Zod is an authoring convenience only.

ToolContext

Every tool factory receives a ToolContext carrying everything the tool needs to act on behalf of the current turn:

  • instanceId - the instance slug (not a UUID). Most tool-level operations key off the slug; the few places that need the UUID resolve it via resolveInstanceId().
  • secrets - decrypted per-instance secrets and any tool-declared requiredSecrets values.
  • audit - a scoped AuditLogger that already carries the tool name, instance, and conversation context.
  • conversationId - for correlation in audit logs and tool results.
  • attachments - images, files, audio carried by the current user message.
  • apiKeys - per-instance AI provider keys, used by tools that themselves call the LLM. With managed services it carries the keys of the agent’s chat provider connection only, never an embedder’s or another role’s, and a dev-mode tool never receives it.
  • provider - the instance’s AI provider name (openai, anthropic, bedrock) for tool-level LLM calls.

The audit logger writes one row per tool call into a dedicated audit table. Sensitive arguments (Slack message bodies, secret values) are deliberately not logged - only the field’s length or a redacted marker.

Strict-mode Zod constraints

To keep tool schemas portable across OpenAI and Anthropic function-calling formats, Polyant enforces a small set of Zod conventions. There is no separate strict-mode.ts module - the contract is codified entirely in the test suite at packages/engine/src/agents/tools/strict-mode.test.ts, which iterates over every registered tool and fails the build on any violation. The forbidden patterns are:

  • No .url() - many models reject the JSON Schema format: "uri" annotation. Use plain z.string() with documentation.
  • No .optional() without .nullable() - optional-but-not-nullable produces inconsistent behaviour across providers. Use z.string().nullable() or z.string().nullish().
  • No z.record(z.unknown()) - open-ended records confuse function-calling. Pin the shape (z.record(z.string(), z.string())) or use a typed object.

Because the check lives in CI, schema violations surface in dev/CI before reaching production.

Per-instance enablement

The registry holds every tool the framework knows about. What an individual instance can actually use is gated by the instance_tools table - one row per (instance_id, tool_name) with an enabled flag.

instance_tools is auto-synchronised:

  • On boot, every registered tool is upserted into the tools catalogue table.
  • When a skill is enabled on an instance, the skill’s requiredTools are auto-enabled on instance_tools.
  • When a skill is disabled, the dependency is recomputed (no orphan enablement).
  • Operators can also toggle tools directly from the admin panel.

The supervisor’s buildTools pulls the enabled set via getEnabledToolNames() and only constructs Vercel AI SDK tool({...}) instances for those names. A tool whose required secrets are missing is marked on its Tools tab row (Missing …) so operators can spot the gap.

syncToolsToDb and the orphan cascade

On every boot, syncToolsToDb() (in tools-sync.ts) is the source of truth: it reconciles the tools catalogue table against whatever the registry just loaded from disk. Tools present in the DB but not in the registry (e.g. a *.tool.ts file you deleted, renamed, or refactored away) are deleted from tools. A foreign-key cascade on instance_tools(tool_name) then sweeps every per-instance enablement row referencing the now-gone tool. The net effect: rename a tool on disk, restart the engine, and every instance that had the old name enabled will silently lose that row. Re-enable from the admin UI under the new name - there is no automated migration. The same cascade removes skill-tool links (skill_tools), which is what happened to every tool that moved into a plugin in 1.2.0: the old flat names left the catalogue on first boot, together with every agent’s enablement and every skill’s link to them. See Tools → Plugins for the name mapping.

Conditional visibility - what an agent actually sees on a given turn

The registry holds every tool, but the set actually handed to the LLM on a specific turn is filtered through five layers. A tool can disappear from a turn without ever being “disabled” in the admin UI.

1. Per-agent enablement (instance_tools table)

The standard gate. getEnabledToolNames(instanceId) returns the union of tools toggled on for this agent (manually or via skill requiredTools). Tools not in the set are skipped - except harness tools (see below), which bypass this layer entirely.

2. Feature flags on the agent (memoryEnabled, knowledgeEnabled)

Two flags on the instances row act as category-level kill-switches in the supervisor:

  • memoryEnabled = false → tools with category: "memory" (searchMemory, saveMemory) are skipped.
  • knowledgeEnabled = false → tools with category: "knowledge" (searchKnowledge, getKnowledge, writeKnowledge) are skipped.

The flags trump per-agent enablement: even if instance_tools has the row, the tool is dropped when the flag is off. This is what lets you offer a fast, memory-less agent variant by flipping one switch.

3. Missing requiredSecrets

Tools that declare requiredSecrets with optional: false are silently skipped whenever any of those secrets is absent, both from the agent’s secrets and, for a key an organization may share, from its organization’s shared credentials. Examples:

  • webSearch → needs tavily_api_key (or another configured provider key).
  • slackPostMessage → needs the agent’s Slack credentials.

With managed services a tool that requires a provider key, such as openai_api_key in the example above, is always skipped: provider keys are held by provider connections, and the copies the upgrade left in the secrets reach no tool. A tool that calls a model uses ctx.apiKeys instead.

The memory and knowledge tools (searchMemory, saveMemory, searchKnowledge) declare no secret. They need credentials for the agent’s embedder, whichever it is, and an embedder without them is reported on the agent’s Status section rather than by hiding the tools.

The skip is silent by design: the LLM never sees the tool, so it cannot try to call it and fail. Operators see the missing values named on the tool’s row in the Tools tab.

4. Harness tools - only injected in specific runtimes

Some tools are deliberately not user-toggled. They are wired into the framework for a specific runtime path and would be meaningless (or harmful) anywhere else. They declare harness: true plus a category and are:

  • Hidden from the admin panel’s tool list.
  • Skipped by the instance_tools enablement gate.
  • Injected by the supervisor only when it runs with a matching includeHarness: Set<string>.

The supervisor’s caller decides which harness categories to admit. Today’s runtimes:

RuntimeincludeHarness valueTools injected
Inbound (chat)(none)none - chat turns get only the standard catalogue
Scheduled task(none)none - same pipeline as Inbound
Sub-agent via spawnTask(none, parent’s tools minus spawnTask)none - sub-agents inherit parent tools, harness excluded
Room cycle{ "room" }send_message_to_human, mark_room_event_completed, compact_room_history
Webhook trigger{ <outboundChannel> } (e.g. "whatsapp")send_outbound_message (always when triggered), plus channel-specific tools like send_whatsapp_template when the trigger’s outboundChannel matches

The categories are not interchangeable: a Room turn cannot send a WhatsApp template, and a webhook trigger cannot mark Room events completed. Each runtime carries the exact subset it needs.

spawnTask itself is a special case. Its task.tool.ts file registers a catalogue entry (category: "agent", metaTool: true), so it is enabled per agent like any other tool, but the supervisor builds the real tool separately with createTaskTool(), so it can be passed the parent’s tool list minus itself (which prevents recursion). It is never propagated to sub-agents. A new agent starts with it enabled, beside createSkill and readSkill. Before 1.2.0 the file was named so that the loader never picked it up, so no agent could enable it: an existing agent that should delegate needs it enabled from the Tools tab.

5. Agent-to-agent tools - synthesised on the fly

When an instance_tools row has a name of the form agent:<targetSlug>, the supervisor synthesises a one-off tool at request time that lets this agent dispatch a task to the target agent. The dynamic tool is built only if the target agent has its agent channel enabled, and nesting is capped at depth 1 to keep loops bounded.

These tools exist nowhere on disk - they are pure synthesis driven by per-agent configuration.

Putting it together

The order of evaluation in buildTools() is roughly:

for every tool in registry: if tool.harness: keep iff tool.category ∈ includeHarness else: skip iff not enabled in instance_tools skip iff memoryEnabled=false and category="memory" skip iff knowledgeEnabled=false and category="knowledge" skip iff any non-optional requiredSecret is missing then: add spawnTask (if enabled in instance_tools, parent context) add agent:<slug> tools (synthesised from instance_tools rows)

Net effect: the set of tools the LLM sees is a function of the agent’s row in instances, its instance_tools rows, its instance_secrets rows, and the runtime path. The same agent on the same DB exposes a different toolset to chat vs Room vs a WhatsApp webhook trigger.

Plugins

A plugin carries tools (and hooks, and OAuth providers) outside the engine’s own directory. It is a directory with a plugin.json manifest at its root and its own *.tool.ts and *.hook.ts files, written with the same defineTool and defineHook from the plugin SDK. The engine loads it at boot and puts the plugin’s namespace in front of every tool name, so a plugin tool reaches the catalogue as <namespace>:<tool> (hubspot:contact) and can never collide with a core tool. Loading a plugin offers it to nobody: with managed services an organization installs a marketplace plugin, or a platform admin grants it one baked into the image, and the workspace must have it assigned (see Plugins are installed per organization).

{ "name": "acme-tools", "version": "1.0.0", "engine": ">=1.0.0", "namespace": "acme", "displayName": "Acme", "description": "Order status and returns from the Acme back office.", "toolsDir": "tools", "hooksDir": "hooks", "oauthProviders": [], "system": { "apk": [], "npmGlobal": [], "env": {} } }
FieldRequiredMeaning
nameyesStable plugin id, also the conventional install directory name.
versionyesThe plugin’s own version, independent of the engine’s.
engineyesSemver range of engine versions the plugin runs on. Only the numeric part of the engine version is compared, so a managed-services -ee build satisfies >=1.0.0. An invalid range, or one the engine does not satisfy, skips the plugin with a boot warning.
namespacenoPrefix put in front of every tool name. Defaults to name; an empty string is refused, so there is no unprefixed plugin.
displayNamenoHow the admin panel names the plugin, in the Tools tab’s Origin column and in the tool picker. Without it the panel humanizes the namespace.
descriptionnoOne sentence shown when the tool picker is browsed by plugin.
toolsDirnoDirectory, relative to the plugin root, scanned for *.tool.ts. Defaults to tools.
hooksDirnoDirectory scanned for *.hook.ts. Defaults to hooks.
oauthProvidersnoOAuth providers the plugin adds to the engine’s broker: name, authorizeUrl, tokenUrl, scope, and optionally extraAuthorizeParams and pkce. Provider names are global keys, so two incompatible definitions need distinct names.
systemnoWhat the runtime image must carry for the plugin’s tools: apk (Alpine packages), npmGlobal (npm packages installed globally, for the executables they put on PATH) and env (environment variables set in the image). Read at build time, so a plugin with a system block is available only baked into the image; the marketplace refuses it.

displayName and description only change how the panel presents the plugin. Its tools are still enabled one by one; a plugin is never switched as a whole. A manifest that is not valid JSON, or that fails validation, is skipped with a warning naming the file.

Where the engine finds plugins. At boot the loader scans the absolute roots listed in PLUGIN_DIRS first (these win when two plugins share a name), then the immediate subdirectories of the convention plugin directory. Use a real directory, not a symlink.

How a plugin gets into an image. The Docker build compiles every plugin placed under packages/engine/src/plugins/<name>: it installs each plugin’s own dependencies, compiles its TypeScript in place, and the loader finds the result at boot. system is read at this point and only here. The build installs the union of every plugin’s apk and npmGlobal packages into the runtime stage and exports the collected env, so an image built without a plugin does not carry what that plugin needs, and a plugin whose binary is missing fails on the tool call, never at boot. Two plugins that set the same variable to different values fail the build. There is no allowlist: including a plugin in a build is already the decision to trust it.

Remote tools: MCP servers

Beyond the built-in registry and the plugin namespaces, an agent can consume tools from an external Model Context Protocol server. The engine acts as the MCP client.

Where they differ from local tools, and why each difference exists:

Local toolMCP tool
Discoveryself-registered at bootlisted from the server, per turn
Name seen by the modelits ownmcp__<server>__<tool>
Descriptionoursthe server’s, sanitised and tagged as untrusted
Failure modethrows, returns an error stringserver skipped for the turn on timeout

A remote server is treated as hostile input to the prompt, because that is what it is: its tool descriptions are rendered into the stable, cached, highest-trust part of the system prompt. So descriptions are truncated and wrapped in an untrusted_remote_description tag, names are namespaced and length-capped so a remote tool cannot impersonate a core one, and the round trip is bounded - a slow server is skipped for that turn rather than stalling it.

The server URL is checked when it is dialled, not only when it is saved, against loopback, private, link-local, unique-local and unspecified addresses in every notation including IPv4-mapped IPv6. That is an SSRF guard: without it an admin-supplied URL would be a way to make the engine fetch its own cloud metadata endpoint.

See MCP servers for the admin surface.

Input examples

Every tool can declare inputExamples - short labelled snippets that the supervisor inlines next to the Zod schema description. Models pick up tool usage faster when they see a concrete example than from a schema alone. Each example is validated at boot against parameters.partial() so a stale example cannot ship.

Keep example labels to a single sentence; keep the input objects minimal.

Execute contract: never throw

Tool execute functions follow a strict convention:

return { success: true, ...payload }; // or return { success: false, error: "human-readable explanation" };

They never throw. A throw bubbles into the supervisor as a runtime crash and tends to wedge the tool loop in retry storms. Catching at the tool boundary and returning a structured failure lets the LLM read the error, recover, and either try a different tool or surface a clean message to the user.

Execution isolation: tools that touch the filesystem or spawn subprocesses

A handful of tools do not just call HTTP APIs - they read or write files, or they spawn an external process. These need stricter guardrails than a normal API-call tool, because a misstep can clobber the host filesystem or leak secrets through the process environment. Polyant sandboxes them along three orthogonal axes.

Per-conversation workspace, never the host filesystem

Filesystem-touching tools (readFile, writeFile, listDirectory) operate inside a per-conversation workspace:

packages/engine/workspaces/{instanceId}/conversations/{sanitizedConversationId}/

The root is fixed; it is not deployment configuration. The instance slug is validated against ^[a-z0-9][a-z0-9-]*$. The conversation id is sanitised - any character outside [a-zA-Z0-9._-] is replaced with _ - so a Slack id like slack:channel:U123 becomes slack_channel_U123 and never appears literally in a path.

Every path the tool resolves passes through workspace-utils.ts:

  • resolveWorkspacePath() rejects null bytes, absolute paths, and ../ traversal that would escape the workspace.
  • After string normalisation it walks the path to the first existing ancestor and calls realpath() on it, then checks containment again - catching symlinks that would otherwise re-route the path outside the sandbox.
  • The same two checks gate an absolute path produced by one tool and later handed to another (assertInsideConversationWorkspace).

Net effect: a malicious or confused agent cannot read /etc/passwd, write into a sibling conversation’s directory, or follow a symlink to escape its own workspace.

readFile also bounds what it returns: at most 500 lines per call. The model reads further with offset (first line, from 1) and limit (at most 500), or takes the last N lines with tail, and every result reports the file’s total line count. A file over 512 KB is refused.

The subprocess environment is built, not inherited

The engine runs with the deployment’s full environment - DATABASE_URL, ENCRYPTION_KEY, AUTH_SECRET, every provider API key. None of it reaches a child process. runSkillScript, the one tool that still spawns one, hands execFile an environment it constructs from scratch: the skill’s own encrypted env vars, SKILL_DIR, and PATH. Nothing else is passed through, so a script sees the credentials its skill declared and no others.

execFile(scriptPath, args, { env: { ...skillEnv, SKILL_DIR: tempDir, PATH: process.env.PATH }, cwd: tempDir, timeout, maxBuffer: MAX_BUFFER, })

The script runs in a temporary directory that is its own working directory, not in the conversation workspace.

Subprocess timeouts and buffer caps

The spawn is bounded on both axes: the timeout defaults to 30 s and the caller may raise it only as far as 120 s, and stdout and stderr are capped at 1 MB combined.

A timeout returns a structured { success: false, error: "..." } to the LLM - the supervisor sees the failure as a normal tool error and decides whether to retry or report it upstream.

What is not sandboxed

Be honest about the threat model:

  • No network egress filtering. A skill script can reach any host the engine itself can reach.
  • No CPU / RAM limits. A runaway script will consume whatever the OS lets it until the timeout fires. In containerised deployments, the container’s resource limits are the only backstop.
  • No syscall sandbox. There is no seccomp / AppArmor / SELinux profile. The subprocess runs as the engine’s OS user.

The trust model is: the operator controls which agents are deployed and which secrets they hold; the skill catalogue and tool gating control which scripts can run at all; the filesystem boundary and the constructed environment prevent collateral damage within that trust envelope. Tighter sandboxing (gVisor, ephemeral containers per tool call) is the natural next step for deployments that need to run code from untrusted sources.

How it works

boot: loadAllTools() readdir(*.tool.ts) | v dynamic import each --> collect default export (defineTool) | v prune missing requiredEnv | v syncToolsToDb() -- upserts catalogue + reconciles instance_tools per request: +-------------------------+ user msg --> | Supervisor.buildTools() | +-----------+-------------+ | v getEnabledToolNames(instanceId) | v for each enabled tool: def = registry.get(name) inst = def.create(ToolContext { instanceId, secrets, audit, conversationId, attachments, apiKeys, provider }) tool({ description: def.description, parameters: inst.parameters, execute: wrap(inst.execute) }) // logs + audit | v +-------------------------+ | Vercel AI SDK tool loop | | (maxSteps = 15) | +-------------------------+

Code reference

  • packages/engine/src/agents/tools/registry.ts - buildTool, ToolDefinition, ToolContext, loadAllTools, listAvailableTools.
  • @polyant-ai/plugin-sdk - defineTool, and the ToolSpec shape a tool file exports.
  • packages/engine/src/agents/tools/strict-mode-lint.ts and strict-mode.test.ts - the enforced schema constraints: no forbidden format (uri, email, uuid, date-time, date, time, ipv4, ipv6), every property listed in required, no unbounded additionalProperties, and a legal tool name.
  • packages/engine/src/agents/tools/tools-sync.ts - syncToolsToDb() and the instance_tools reconciliation logic.
  • packages/engine/src/agents/tools/tools.schema.ts - Catalogue table.
  • packages/engine/src/agents/supervisor/index.ts - buildTools() consumer of the registry.
  • packages/engine/src/audit/audit-logger.ts - The scoped audit logger passed via ToolContext.
  • packages/engine/src/agents/tools/shared/workspace-utils.ts - resolveWorkspacePath, assertInsideConversationWorkspace, sanitizeConversationId, symlink containment checks.
  • packages/engine/src/agents/tools/run-skill-script.tool.ts - The one tool that spawns a subprocess: constructed environment, temp working directory, timeout and buffer caps.
  • Sample tools: search-memory.tool.ts, web-search.tool.ts, http-request.tool.ts, slack-post-message.tool.ts, schedule-task.tool.ts, read-skill.tool.ts, task.tool.ts (spawnTask), room-mark-completed.tool.ts (harness).

See also

Last updated on