DocsPlugins

Examples

Four complete plugins. Each is a single file you can drop into ~/.config/podcode/plugins/ and register by absolute path.

Audit log

Writes one JSON line per tool call to a file — the smallest useful plugin, and a good template for anything that observes without interfering.

audit-log.mjs
import { appendFileSync } from "node:fs"

export default {
  id: "audit-log",

  server(_input, { config }) {
    const file = config?.file
    if (!file) throw new Error("audit-log: plugin_config.audit-log.file is required")

    const write = (entry) => {
      try {
        appendFileSync(file, `${JSON.stringify(entry)}\n`)
      } catch {
        // Never let logging break a turn.
      }
    }

    return {
      "tool.execute.after": (input) => {
        write({
          at: new Date().toISOString(),
          session: input.ctx.sessionID,
          agent: input.ctx.agent,
          tool: input.toolID,
          title: input.result.title,
          bytes: input.result.output.length,
        })
        return undefined
      },
    }
  },
}
JSON
{
  "plugin": ["/Users/you/.config/podcode/plugins/audit-log.mjs"],
  "plugin_config": { "audit-log": { "file": "/Users/you/podcode-audit.jsonl" } }
}

Note the try/catch around the write. A hook that throws is contained, but it also loses every subsequent line in that call — swallowing the error keeps the log best-effort, which is what a log should be.

Cost guard

Caps output tokens and pins temperature, per agent profile. chat.params runs before every model call, so this is the lever for house-wide sampling policy.

cost-guard.mjs
export default {
  id: "cost-guard",

  server(_input, { config }) {
    const maxTokens = config?.maxOutputTokens ?? 4096
    const temperature = config?.temperature

    return {
      "chat.params": (params) => {
        const next = { ...params }

        // Only ever lower the cap — raising it past the model's real limit
        // is clamped away anyway.
        if (params.maxOutputTokens === undefined || params.maxOutputTokens > maxTokens) {
          next.maxOutputTokens = maxTokens
        }
        if (temperature !== undefined) next.temperature = temperature

        return next
      },
    }
  },
}

Redact secrets from tool output

Rewrites result.output before it reaches the model. Useful when a tool can surface credentials from a config file or a process environment.

redact.mjs
const PATTERNS = [
  /\b(sk-[A-Za-z0-9]{20,})\b/g,          // OpenAI-style keys
  /\b(ghp_[A-Za-z0-9]{30,})\b/g,         // GitHub tokens
  /\b(AKIA[0-9A-Z]{16})\b/g,             // AWS access key ids
]

export default {
  id: "redact",

  server(_input, { config }) {
    const extra = (config?.patterns ?? []).map((p) => new RegExp(p, "g"))
    const all = [...PATTERNS, ...extra]

    return {
      "tool.execute.after": (input) => {
        const output = all.reduce((acc, re) => acc.replace(re, "[redacted]"), input.result.output)
        if (output === input.result.output) return undefined

        // Return the whole payload — returning only `result` would drop
        // toolID, args, and ctx.
        return { ...input, result: { ...input.result, output } }
      },
    }
  },
}

This only covers the text the model reads. result.metadata and any attachments pass through untouched, so redact those too if your tools put sensitive values there.

A custom tool: ticket lookup

Gives the agent a way to read your issue tracker, so it can pull acceptance criteria into a session instead of guessing at them.

tickets.mjs
import { z } from "zod"

export default {
  id: "tickets",

  server(_input, { config }) {
    const { baseUrl, token } = config ?? {}
    if (!baseUrl || !token) {
      throw new Error("tickets: plugin_config.tickets needs { baseUrl, token }")
    }

    return {
      tool: () => [
        {
          id: "ticket_read",
          description:
            "Read an issue from the team tracker by key (e.g. 'ENG-482'). Returns the " +
            "title, status, description, and acceptance criteria. Use this before " +
            "implementing work described by a ticket key.",
          parameters: z.object({
            key: z.string().describe("Ticket key, e.g. 'ENG-482'."),
          }),
          idempotent: true,

          async execute({ key }, ctx) {
            ctx.metadata({ title: `fetching ${key}` })

            const res = await fetch(`${baseUrl}/issues/${encodeURIComponent(key)}`, {
              headers: { authorization: `Bearer ${token}` },
              signal: ctx.abort,
            })
            if (res.status === 404) throw new Error(`No ticket named ${key}.`)
            if (!res.ok) throw new Error(`Tracker returned ${res.status} for ${key}.`)

            const t = await res.json()
            return {
              title: `${key} — ${t.status}`,
              metadata: { status: t.status, assignee: t.assignee },
              output: [
                `${key}: ${t.title}`,
                `Status: ${t.status}`,
                "",
                t.description,
                "",
                "Acceptance criteria:",
                ...(t.acceptanceCriteria ?? []).map((c) => `- ${c}`),
              ].join("\n"),
            }
          },
        },
      ],
    }
  },
}

Three habits from this one are worth carrying into every tool you write:

  • Validate config in server() and throw. A misconfigured plugin that removes itself at boot beats one that fails on every call.
  • Pass ctx.abort to fetch. Otherwise an aborted session leaves your request running.
  • Throw from execute with a message the model can act on. Unlike hook throws, these reach the model as a failed tool call — No ticket named ENG-482. tells it what to do next; Request failed does not.

Requires zod resolvable from the plugin file — see getting dependencies to resolve.