DocsPlugins

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.

~/.config/podcode/plugins/deploy-status.mjs
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

FieldTypeRequiredNotes
idstringyesThe name the model calls. Snake case matches the built-ins
descriptionstringyesThe model's only guide to when this tool applies. Write it for the model, not for a human
parametersZod schemayesValidated before execute runs
execute(args, ctx) => Promise<ToolResult>yesYour implementation
truncation"head" | "tail"noWhich end of an oversized output survives. Defaults to "head"
idempotentbooleannoMark 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:

FieldWhat it gives you
sessionID, messageID, callIDIdentity of the call
parentID, rootIDSet when the tool runs inside a sub-agent session
agentThe agent profile that invoked the tool
messagesConversation history, read lazily — the DB read only happens if you touch it
mainCwdThe user's primary workspace directory
abortAbortSignal 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.

JavaScript
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

JavaScript
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_resize

Registering 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.

Shell
cd ~/.config/podcode/plugins && npm init -y && npm install zod@^3