Hook reference
Four hooks are defined and wired today: tool, chat.params, tool.execute.before, and tool.execute.after. This page covers the pipeline rules that apply to all of them, then each hook's payload.
How the pipeline works
Every hook follows the same contract.
Plugins run in order. Config-listed plugins first, in the order they appear in the plugin array, then any auto-discovered files alphabetically.
Output threads forward. Each plugin receives the value the previous plugin returned. The last return value is what the server actually uses.
undefined means "no change." Return nothing and the value passes through untouched. This is the correct return for a hook that only observes.
"chat.params": (params) => {
metrics.record(params.temperature)
return undefined // observe only
}Return the whole payload, not a fragment. For the tool.execute.* hooks the payload is an object with several fields; returning only the piece you changed drops the rest.
// ✅ spread the input back
"tool.execute.before": (input) => ({
...input,
args: { ...input.args, timeout: 5000 },
})
// ❌ throws away toolID and ctx
"tool.execute.before": (input) => ({ args: patched })Sync or async, both fine. Async hooks are awaited before the next plugin runs.
When a hook misbehaves
Hooks are isolated. Two failure modes, one outcome:
- A hook that throws is caught and logged as
plugin: hook threw. - A hook that takes longer than 5 seconds is abandoned and logged as
exceeded 5000ms; dropping its contribution. The underlying promise is left to settle on its own — the work isn't cancelled, only its result is ignored.
In both cases that plugin's contribution is discarded, the pipeline continues from the pre-throw value, and later plugins still run. The turn is never interrupted.
Because chat.params is awaited inline before every model call, and the tool.execute.* hooks before and after every tool, keep them cheap. Anything that could block — a network call, a large file read — belongs behind a cache or in the background.
tool
Registers custom tools. Called once, at load time, and its result is cached — it is never called again.
tool: () => [
{
id: "my_tool",
description: "What the model reads to decide when to call this.",
parameters: z.object({ query: z.string() }),
async execute(args, ctx) {
return { title: "my_tool", output: "result text" }
},
},
]Input: { agent?: string } — reserved for per-agent tool sets. It is called with {} today, so treat agent as always absent.
Output: ToolDef[], or undefined for none. Anything that isn't an array is ignored.
A throw here is caught (plugin: tool hook threw at init) and the plugin loads with zero tools rather than being dropped entirely.
Tool ids are merged into the built-in set by id, so a plugin can replace a built-in tool — except for a protected list (read, write, edit, bash, webfetch, task, the browser_* family, and others) whose execute carries a security gate. Attempting to override one is refused and logged.
Custom tools covers ToolDef and the execution context in full.
chat.params
Runs before every model call, on every session, after Podcode has computed its own defaults and before the request goes to the provider.
"chat.params": (params) => ({ ...params, temperature: 0.1 })Input and output:
| Field | Type | Notes |
|---|---|---|
temperature | number | undefined | Podcode's resolved default for the model |
topP | number | undefined | Not set by Podcode — undefined unless a plugin sets it |
topK | number | undefined | Not set by Podcode — undefined unless a plugin sets it |
maxOutputTokens | number | undefined | Podcode's resolved output cap |
options | Record<string, unknown> | undefined | Provider-specific passthrough |
Fields the caller didn't specify arrive as undefined on purpose, so a plugin can tell "nobody asked for this" apart from "somebody asked for zero."
Two behaviors to know:
maxOutputTokensis clamped afterwards. Whatever you return is capped at the model's real output limit. Raising it past that ceiling has no effect; lowering it works.optionsmerges over Podcode's own provider options, including the reasoning-effort variant it computes for reasoning-capable models. Your keys win — which is the point, and also the way to accidentally break reasoning on a model you didn't have in mind.
tool.execute.before
Runs after the arguments have been validated against the tool's schema and after Podcode's own pre-execution policies (permissions, loop guards), immediately before execute.
"tool.execute.before": (input) => {
if (input.toolID !== "bash") return undefined
return { ...input, args: { ...input.args, timeout: 10_000 } }
}Input and output:
| Field | Type | Notes |
|---|---|---|
toolID | string | e.g. "read", "bash", or your own tool's id |
args | unknown | Already parsed and validated. Typed as unknown — narrow it yourself |
ctx | ToolContext | Session, message, and call ids, the agent name, the abort signal, and helpers |
Whatever you return as args is what execute receives. args is unknown because the hook sees every tool, so check toolID before assuming a shape.
tool.execute.after
Runs immediately after execute returns, before Podcode's post-execution guards and before the result reaches the model.
"tool.execute.after": (input) => ({
...input,
result: { ...input.result, output: redact(input.result.output) },
})Input and output: everything from tool.execute.before, plus:
| Field | Type | Notes |
|---|---|---|
result | ToolResult | { title, output, metadata?, attachments? } |
args | unknown | The final args — after tool.execute.before |
result.output is the text the model sees, which makes this the hook for redaction, truncation, or appending a house-style reminder to specific tools. result.title and result.metadata drive the UI, not the model.
A throw here does not turn into a tool error — like every other hook, it is caught and your contribution is dropped, and the model receives the unmodified result.