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.

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

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