pleach
Build

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/tools SKU is deprecated — folded into @pleach/core. The tool-definition surface described on this page (defineTool / UnifiedToolDefinition, the queryable toolRegistry, schema + hints helpers) ships inside @pleach/core and is reachable at @pleach/core/tools (and @pleach/core/tools/unified). The concept and API below are unchanged — only the separate @pleach/tools package 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

FieldTypePurpose
namestringUnique identifier; what the LLM emits in tool_calls[].name
descriptionstringWhat the tool does — the LLM reads this to decide when to call it
inputSchemaZodType<TInput>Validated before execute runs; invalid args throw before dispatch
execute(input, ctx) => Promise<TOutput>The implementation

Optional fields

FieldTypePurpose
outputSchemaZodType<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:

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

FieldTypeUse
toolCallIdstringCorrelate audit + event log rows
signalAbortSignal?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:

StrategyConcurrencyUse case
serial1Writes, side-effecting tools, dependency chains
parallelUp to BatchingConfig.maxConcurrencyIndependent reads (database lookups, API calls)
chunkedGrouped by key — parallel across groups, serial withinPer-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

On this page