Custom tools
The tool hook is how a plugin adds a capability the agent didn't have. The tools you return are merged into the built-in set and offered to the model exactly like read or bash — same schema validation, same permission prompts, same truncation.
import { z } from "zod"
export default {
id: "deploy-status",
server(_input, { config }) {
const endpoint = config?.endpoint
if (!endpoint) throw new Error("deploy-status: set plugin_config.deploy-status.endpoint")
return {
tool: () => [
{
id: "deploy_status",
description:
"Check the current deployment status of a service. Returns the live " +
"release tag, health, and the time of the last deploy.",
parameters: z.object({
service: z.string().describe("Service name, e.g. 'api' or 'web'."),
}),
idempotent: true,
async execute({ service }, ctx) {
const res = await fetch(`${endpoint}/status/${service}`, {
signal: ctx.abort,
})
if (!res.ok) throw new Error(`status ${res.status} for ${service}`)
const body = await res.json()
return {
title: `${service} — ${body.health}`,
metadata: { release: body.release },
output: `Service ${service} is ${body.health} on release ${body.release}, deployed ${body.deployedAt}.`,
}
},
},
],
}
},
}ToolDef
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | yes | The name the model calls. Snake case matches the built-ins |
description | string | yes | The model's only guide to when this tool applies. Write it for the model, not for a human |
parameters | Zod schema | yes | Validated before execute runs |
execute | (args, ctx) => Promise<ToolResult> | yes | Your implementation |
truncation | "head" | "tail" | no | Which end of an oversized output survives. Defaults to "head" |
idempotent | boolean | no | Mark read-only, safely repeatable tools so error self-correction can retry the turn. Defaults to false |
parameters must be a Zod v3 schema, and it has to be a zod that the server can interoperate with — Podcode builds on zod@^3. Use .describe() on each field; that text reaches the model and does much of the work of a good tool.
The execution context
execute(args, ctx) receives a validated args and a ToolContext:
| Field | What it gives you |
|---|---|
sessionID, messageID, callID | Identity of the call |
parentID, rootID | Set when the tool runs inside a sub-agent session |
agent | The agent profile that invoked the tool |
messages | Conversation history, read lazily — the DB read only happens if you touch it |
mainCwd | The user's primary workspace directory |
abort | AbortSignal that fires when the session is aborted. Pass it to fetch and long loops |
metadata({ title, metadata }) | Update the tool's live UI state mid-execution |
ask({ permission, patterns, always?, metadata? }) | Block until the user grants permission; throws on reject |
recordChange({ file, op, oldContent, newContent }) | Record a file modification in the change journal |
Asking for permission
A tool that touches the filesystem, the network, or anything else consequential should go through ctx.ask rather than deciding for itself. It surfaces the same prompt the built-ins use, and throws if the user declines — let that throw propagate.
async execute({ path, body }, ctx) {
await ctx.ask({
permission: "write",
patterns: [path],
metadata: { preview: body.slice(0, 200) },
})
const previous = await readIfExists(path)
await writeFile(path, body)
ctx.recordChange({
file: path,
op: previous === null ? "create" : "modify",
oldContent: previous,
newContent: body,
})
return { title: `wrote ${basename(path)}`, output: `Wrote ${body.length} bytes to ${path}.` }
}recordChange is what puts your edit in the Changes panel and makes it revertable. Skipping it leaves a file the user can see but can't roll back.
Returning a result
return {
title: "short label for the UI", // ≤ 60 chars
output: "the text the model reads",
metadata: { anything: "for the UI" },
attachments: [{ mime: "image/png", url: "data:image/png;base64,..." }],
}output is the entire model-facing surface — title and metadata are UI only. Write output as if it were a message to the model, because it is.
Throwing from execute is normal and supported: the error is captured and surfaced to the model as a failed tool call, which it can then react to. This is unlike the hook pipeline, where throws are swallowed.
Replacing built-in tools
Plugin tools merge into the built-in set by id, so registering id: "grep" replaces the built-in grep.
A protected list refuses that override, because those tools carry their security gate inside execute and a gate-free replacement would be a way around it:
read list write edit apply_patch bash glob grep symbols
webfetch task sync_to_main
browser_navigate browser_click browser_type browser_snapshot
browser_screenshot browser_look browser_select browser_scroll
browser_measure browser_diff browser_resizeRegistering one of these ids is refused with plugin: refusing to override protected built-in tool and the rest of your tools still load.
Getting dependencies to resolve
Your plugin file is imported by the server process, so its imports resolve through Node from the plugin file's own location. import { z } from "zod" only works if zod is resolvable from there — a node_modules next to the file, or a directory above it.
The dependency-free version of the same tool is to hand-roll the schema object your zod version produces, but the practical answer is simpler: give your plugin directory a package.json and install into it.
cd ~/.config/podcode/plugins && npm init -y && npm install zod@^3