DocsPlugins

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.

JavaScript
"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.

JavaScript
// ✅ 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.

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

JavaScript
"chat.params": (params) => ({ ...params, temperature: 0.1 })

Input and output:

FieldTypeNotes
temperaturenumber | undefinedPodcode's resolved default for the model
topPnumber | undefinedNot set by Podcode — undefined unless a plugin sets it
topKnumber | undefinedNot set by Podcode — undefined unless a plugin sets it
maxOutputTokensnumber | undefinedPodcode's resolved output cap
optionsRecord<string, unknown> | undefinedProvider-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:

  • maxOutputTokens is 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.
  • options merges 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.

JavaScript
"tool.execute.before": (input) => {
  if (input.toolID !== "bash") return undefined
  return { ...input, args: { ...input.args, timeout: 10_000 } }
}

Input and output:

FieldTypeNotes
toolIDstringe.g. "read", "bash", or your own tool's id
argsunknownAlready parsed and validated. Typed as unknown — narrow it yourself
ctxToolContextSession, 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.

JavaScript
"tool.execute.after": (input) => ({
  ...input,
  result: { ...input.result, output: redact(input.result.output) },
})

Input and output: everything from tool.execute.before, plus:

FieldTypeNotes
resultToolResult{ title, output, metadata?, attachments? }
argsunknownThe 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.