# Tools (/docs/tools)



> **Tools are a thematic island.** Not one of the [six cluster
> triplets](/docs/concept-clusters#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](/docs/concept-clusters#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](/docs/error-codes). See [Stream events](/docs/stream-events)
for the per-tool lifecycle on the wire.

```typescript
import { defineTool } from "@pleach/core";
import type { ToolDefinition, ToolContext } from "@pleach/core";
```

<SourceMeta source="{ label: &#x22;src/tools/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/tools&#x22; }" />

## `defineTool` [#definetool]

Identity at runtime; the type parameters give callers an inferred
`ToolDefinition<TInput, TOutput>` without ceremony.

```typescript
// 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 [#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 [#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`](#safetytier)
below.

## `safetyTier` [#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](#registering-tools-with-a-session) 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:

```typescript
// 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](/docs/fabrication-detection#host-supplied-tool-argument-fabrication-detectors)
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` [#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.

```typescript
async execute(input, ctx) {
  const child = spawn("expensive-cli", [input.q], { signal: ctx.signal });
  // ...
}
```

## Registering tools with a session [#registering-tools-with-a-session]

Pass tool names to `createSession`:

```typescript
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:

```typescript
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 [#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:

```tsx
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 [#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:

```typescript
// 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`:

```typescript
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 [#lifecycle-in-the-stream]

A successful tool call produces three stream events:

```text
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`:

```text
tool.failed    { toolCall, error }
```

See [Stream events](/docs/stream-events) for the full payload
shapes.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Base tools" href="/docs/base-tools" description="The batteries-included bundle — math, datetime, scratchpad, unit_convert, text_search, opt-in url_fetch." />

  <Card title="Providers" href="/docs/providers" description="How tools get serialized for the underlying LLM SDK." />

  <Card title="Stream events" href="/docs/stream-events" description="`tool.started` / `tool.delta` / `tool.completed` / `tool.failed`." />

  <Card title="Interrupts" href="/docs/interrupts" description="Pause for human approval before a tool fires." />

  <Card title="Error codes" href="/docs/error-codes" description="The 1xxx tool-error range." />
</Cards>
