Tools
Define, register, and execute tools — the `defineTool` contract, Zod-validated input/output, the per-invocation context, and batching.
Tools are a thematic island. Not one of the six cluster triplets — tooling is one surface end to end (contract + dispatch + result-handling), not a three-concept cluster. See What lives outside the cluster pattern.
The standalone
@pleach/toolsSKU is deprecated — folded into@pleach/core. The tool-definition surface described on this page (defineTool/UnifiedToolDefinition, the queryabletoolRegistry, schema + hints helpers) ships inside@pleach/coreand is reachable at@pleach/core/tools(and@pleach/core/tools/unified). The concept and API below are unchanged — only the separate@pleach/toolspackage is retired. Import the tool contracts from@pleach/core/tools; new code should not depend on@pleach/tools.
Hand tools for the lattice — typed, scoped, batched, returned to
the shed at turn's end. A tool is a named function the LLM can call.
@pleach/core ships the contract (defineTool); the runtime handles
dispatch, streaming partial arguments, execution, and writing the
result back into the conversation. Input and output are Zod-validated; an invalid call
fails before dispatch with a structured error from the
1xxx range. See Stream events
for the per-tool lifecycle on the wire.
import { defineTool } from "@pleach/core";
import type { ToolDefinition, ToolContext } from "@pleach/core";defineTool
Identity at runtime; the type parameters give callers an inferred
ToolDefinition<TInput, TOutput> without ceremony.
// lib/tools/searchCorpus.ts
import { z } from "zod";
import { defineTool } from "@pleach/core";
export const searchCorpus = defineTool({
name: "search_corpus",
description: "Free-text search over the host corpus.",
inputSchema: z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(50).default(10),
}),
outputSchema: z.object({
results: z.array(z.object({
id: z.string(),
title: z.string(),
year: z.number().int(),
})),
}),
async execute(input, ctx) {
const res = await fetch(
`${process.env.CORPUS_URL}/search?q=${encodeURIComponent(input.query)}`,
{ signal: ctx.signal },
);
const data = await res.json();
return { results: data.results.slice(0, input.limit) };
},
});Required fields
| Field | Type | Purpose |
|---|---|---|
name | string | Unique identifier; what the LLM emits in tool_calls[].name |
description | string | What the tool does — the LLM reads this to decide when to call it |
inputSchema | ZodType<TInput> | Validated before execute runs; invalid args throw before dispatch |
execute | (input, ctx) => Promise<TOutput> | The implementation |
Optional fields
| Field | Type | Purpose |
|---|---|---|
outputSchema | ZodType<TOutput> | Validates the return; failures surface as tool.failed with code 1002 |
safetyTier is not a defineTool field — it lives on the
richer registry-level tool shape. See safetyTier
below.
safetyTier
A policy marker the runtime reads when a host-supplied detector flags
a tool argument as fabricated (an argument value with no provenance
in the turn's prior tool results). It is not part of the basic
defineTool contract — ToolDefinition carries only
name/description/inputSchema/outputSchema/execute, so
passing safetyTier to defineTool({ … }) is a TypeScript
excess-property error. It is a field of the richer
UnifiedToolDefinition shape, set through createUnifiedTool (see
Registering tools below).
Three values, three destinations:
| Value | Destination | User can override? |
|---|---|---|
"critical" | A hard-halt pipeline stage short-circuits dispatch with _recoverable: false. The operator's pre-committed safety policy. | No |
"standard" | The InterruptApprovalCard surfaces with a ground-truth panel; the user can accept, edit, or reject. | Yes |
"advisory" | Probe-only — the detector still fires for observability; no halt and no card. | N/A |
Absent on a tool that the host's detector doesn't guard, the field
is a no-op. Absent on a tool the host's detector does guard, the
runtime treats it as "standard".
The field lives on the registry-level UnifiedToolDefinition shape
(reached through @pleach/core/tools), not on the basic
ToolDefinition returned by defineTool. Set it with
createUnifiedTool and register the result:
// lib/tools/dangerousLookup.ts
import { createUnifiedTool, toolRegistry } from "@pleach/core/tools";
export const dangerousLookup = createUnifiedTool({
id: "dangerous_lookup",
name: "dangerous_lookup",
displayName: "Dangerous Lookup",
category: "analysis",
schemaKey: "DangerousLookupInput",
safetyTier: "critical", // hard-halt routing
async execute(input, ctx) { /* ... */ },
});
toolRegistry.register(dangerousLookup);Hosts contribute the detector that decides what "fabricated" means
for their domain — see
Fabrication detection
for the detector contract and the routing rules. The runtime
enforces an invariant: every tool tagged "critical" must be
reachable by the host's detector. CI fails when coverage is missing,
so a "critical" tool can't silently bypass the hard-halt.
ToolContext
Passed as the second arg to execute. Intentionally minimal — the
contract stays portable across hosts.
| Field | Type | Use |
|---|---|---|
toolCallId | string | Correlate audit + event log rows |
signal | AbortSignal? | Per-turn cancel — propagate into every fetch / spawn |
Tools that ignore signal keep burning resources after the user
hits stop. Always thread it through.
async execute(input, ctx) {
const child = spawn("expensive-cli", [input.q], { signal: ctx.signal });
// ...
}Registering tools with a session
Pass tool names to createSession:
const session = await runtime.createSession({
tools: { enabled: ["search_corpus", "calculator"] },
});The runtime looks the names up in the active tool registry. Wire
the registry at runtime construction either through a plugin
(contributeTools is the standard path) or via the legacy
setOrchestratorRegistry shim for hosts mid-migration:
import { setOrchestratorRegistry } from "@pleach/core/tools";
const tools = [searchCorpus, calculator];
setOrchestratorRegistry({
getToolDefinitions: () => tools,
get: (name) => tools.find((t) => t.name === name),
has: (name) => tools.some((t) => t.name === name),
size: tools.length,
});@pleach/tools is the sibling SKU that ships a Zod-validated,
intent-categorized reference catalog (filesystem, HTTP, shell,
structured parse). Install it for the common cases; write
defineTool calls for your domain-specific tools.
Validating arguments before dispatch
useToolValidation(name) validates input shape against the tool's
Zod schema on the client — useful for schema-driven forms that
build a tool call manually:
import { useToolValidation } from "@pleach/core/react";
function SearchForm() {
const [args, setArgs] = useState({ query: "", limit: 10 });
const result = useToolValidation({
id: "call_1",
name: "search_corpus",
arguments: args,
status: "pending",
});
return (
<form onSubmit={(e) => {
e.preventDefault();
if (result?.valid) submitToolCall("search_corpus", args);
}}>
<input value={args.query} onChange={(e) => setArgs({ ...args, query: (e.target as unknown as { value: string }).value })} />
{result && !result.valid && <ErrorList errors={result.errors} />}
</form>
);
}Batching
ToolBatchExecutor groups tool calls the runtime decides can fire
concurrently. The batching strategy per tool defaults to inferred
from the schema; override it explicitly when the inference is
wrong:
// lib/tools/fetchDocument.ts
import { defineTool } from "@pleach/core";
export const fetchDocument = defineTool({
name: "fetch_document",
description: "Fetch full text for one document id.",
inputSchema: z.object({ id: z.string() }),
async execute(input, ctx) { /* ... */ },
});
// `batching` is NOT a `defineTool` field. Override the inferred strategy
// from a plugin via the `contributeBatchingHints` hook (routed through
// `_raw` on `definePleachPlugin`), keyed by tool name:
// contributeBatchingHints: () => [
// { toolName: "fetch_document", strategy: "parallel", maxConcurrency: 5 },
// ]Available strategies:
| Strategy | Concurrency | Use case |
|---|---|---|
serial | 1 | Writes, side-effecting tools, dependency chains |
parallel | Up to BatchingConfig.maxConcurrency | Independent reads (database lookups, API calls) |
chunked | Grouped by key — parallel across groups, serial within | Per-resource serialization (per-user, per-file) |
inferBatchingStrategy(toolDef) reads strategy hints from the
definition; getBatchingStrategyForTool(name) resolves through
the active registry.
Use Semaphore from @pleach/core if you need a hand-rolled
concurrency limit inside execute:
import { Semaphore } from "@pleach/core";
const limit = new Semaphore(4);
async execute(input, ctx) {
return limit.run(async () => fetchOneThing(input, ctx));
}Curbing tool-call runaway on weak-schema models
Some model families over-call tools. They fire oversized batches and
repeat the same tool, burning the token budget before a reactive
repetition guard can catch it. weakToolSchemaBudgetPlugin
(@pleach/core/prompts) injects a proactive prompt directive that
tells the model to self-limit before the first over-call — and only
for the families that need it.
import { createPleachRuntime } from "@pleach/core";
import { weakToolSchemaBudgetPlugin } from "@pleach/core/prompts";
const runtime = createPleachRuntime({
plugins: [
weakToolSchemaBudgetPlugin({ maxBatch: 4, maxSameTool: 2 }),
],
});The plugin gates on the resolved model family (PromptContext.family).
The built-in weak set is google, deepseek, and moonshot; a
strong-schema turn receives no directive, so no prompt tokens are
spent where they aren't needed. Defaults are maxBatch: 4 and
maxSameTool: 2.
Membership is a property of the model family, not of your domain.
Declare your own weak models with createWeakToolSchemaPredicate
(@pleach/core/modelfamily) and pass it as isWeak:
import { createWeakToolSchemaPredicate } from "@pleach/core/modelfamily";
const isWeak = createWeakToolSchemaPredicate({
extend: ["mistral"], // union onto the built-in set
isWeak: (_family, modelId) => // per-model override for a fine-tune
modelId?.includes("acme-retriever-ft") ? true : undefined,
});
weakToolSchemaBudgetPlugin({ isWeak, maxBatch: 4, maxSameTool: 2 });extend unions family names onto the built-in set. The isWeak
callback returns true or false to decide outright, or undefined
to fall through to family-set membership. Through the plugin only the
family axis is consulted — the plugin passes ctx.family alone — so
the per-modelId override applies when you call the predicate
yourself at a host guard or budget, not through the prompt directive.
Call it directly (isWeak("deepseek") === true) if you're wiring your
own reactive budget instead.
Condensing a large tool set: condenseTools
weakToolSchemaBudgetPlugin curbs over-calling with a prompt
directive; it leaves every schema in tools[]. condenseTools is
the other lever — it trims the tools[] array itself when the set is
large enough to threaten the context window. It's an option on the
packaged createPleachRoute surface.
createPleachRoute({
// …
condenseTools: { threshold: 40 }, // condense once the set crosses 40 tools
});Below the threshold the option is inert — every turn is
byte-identical to the uncondensed path. Above it, the generic default
keeps maxSchemaTools full schemas (default 1) and surfaces every
other tool as a one-line name in the condensed system-prompt text.
That's context-window-safe, but a dropped schema can't be pulled back
on its own — so the condensed set is only usable with a discovery
mechanism.
That mechanism is the discovery meta-tool. Supply a curated policy and register it:
condenseTools: {
threshold: 40,
config: {
metaToolName: "discover_tools", // the model calls this to pull a dropped schema back
intentPriorityMap: { // Record<string, readonly string[]>
search: ["web_search", "vector_query"], // keep full schemas when the turn's intent is "search"
write: ["fs_write", "patch_file"],
},
},
};intentPriorityMap is Record<string, readonly string[]> — an intent
name mapped to the tool names that keep their full schema when that
intent is active. Tools not listed for the active intent are condensed
to a one-line name and recovered on demand through the meta-tool.
With the meta-tool registered, the model calls it by name to
re-request a full schema it needs mid-turn. Without it, a real 40+-
tool consumer should either supply the config or pass
condenseTools: false to keep every full schema. Pass a bare
{ threshold } to retune, or false to disable outright.
createPleachRoute wires the meta-tool for you. Hand-rolling a
server instead? Build it directly with createDiscoverToolsTool:
import { createDiscoverToolsTool, DISCOVER_TOOLS_TOOL_NAME } from "@pleach/core";
// DISCOVER_TOOLS_TOOL_NAME === "discover_tools"
const discover = createDiscoverToolsTool(() => runtime.resolvedTools());It takes a getter that returns the current resolved tool set, so the model can pull a dropped schema back by exact name or free-text query against a live snapshot rather than a stale copy.
Lifecycle in the stream
A successful tool call produces three stream events:
tool.started { toolCall: { id, name, args } }
tool.delta { toolCallId, delta } // streaming argument assembly
tool.completed { toolCall, result }A failure swaps the last for tool.failed:
tool.failed { toolCall, error }See Stream events for the full payload shapes.
Where to go next
Base tools
The batteries-included bundle — math, datetime, scratchpad, unit_convert, text_search, opt-in url_fetch.
Providers
How tools get serialized for the underlying LLM SDK.
Stream events
`tool.started` / `tool.delta` / `tool.completed` / `tool.failed`.
Interrupts
Pause for human approval before a tool fires.
Error codes
The 1xxx tool-error range.