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.
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));
}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.