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.
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
},
}
},
}{
"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.
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.
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.
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.aborttofetch. Otherwise an aborted session leaves your request running. - Throw from
executewith 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 faileddoes not.
Requires zod resolvable from the plugin file — see getting dependencies to resolve.