pleach
Build

@pleach/base-tools

Domain-agnostic tool primitives — math, datetime, scratchpad, unit_convert, text_search, json_query, memory, and opt-in web_search, web_fetch, code_exec, ask_user, filesystem, and recall.

⚠️ Deprecated — folded into @pleach/core. These tools now ship inside @pleach/core at the opt-in subpath @pleach/core/base-tools (with the same per-tool subpaths). Import from there — zero extra install for @pleach/core consumers, identical surface. The standalone @pleach/base-tools package is a thin re-export shim, retained only for back-compat, and is no longer maintained. New projects should not add it. The tools themselves are not going away — only the extra package is.

Starter implements for the gardener — shears, watering can, scratchpad, fetcher. @pleach/base-tools is the batteries-included bundle of domain-agnostic tools almost every agent ends up needing. Zero-config: arithmetic, time, a per-session scratchpad, unit conversion, in-text search, JSON extraction, and cross-session memory. Opt-in and injected: web search, web-page reading, code execution, human-in-the-loop, a sandboxed filesystem, and event-log recall. It's a separate SKU from @pleach/tools on purpose: @pleach/tools ships the defineTool primitive and the tool-loader contracts; this package ships ready-made tools that drop straight into a SessionRuntime.

The tools split two ways. The zero-config set works the moment you register the plugin — it needs no keys, no network, no host runtime. The opt-in set follows one rule: refuse by default, inject the capability. A tool that reaches the network, runs code, or opens the filesystem stays off until the host hands it an allowlist, a sandbox runner, or an adapter. The package ships the contract and the guard; your trusted runtime supplies the teeth.

Hosts that hand-roll their toolbelt don't need this package. Hosts that want a sane default surface — without writing seven small Zod schemas to wrap Date.now() — install it.

These tools now ship inside @pleach/core

The base tools have been hoisted into @pleach/core at the opt-in subpath @pleach/core/base-tools. If you already depend on @pleach/core, you can import them with zero extra install:

import { baseToolsPlugin } from "@pleach/core/base-tools";

The subpath is opt-in (it is not part of the top-level @pleach/core barrel), so it stays tree-shakeable. The standalone @pleach/base-tools package below remains published as a thin re-export shim over that subpath — same symbols, two import paths.

Install

The tools ship with @pleach/core already (import from @pleach/core/base-tools). Install the standalone shim only if you want to depend on the SKU by name:

npm install @pleach/base-tools
pnpm add @pleach/base-tools
bun add @pleach/base-tools
import {
  mathTool,
  datetimeTool,
  scratchpadTool,
  unitConvertTool,
  textSearchTool,
} from "@pleach/base-tools";

The import names above are illustrative. The authoritative export shape lives in the package README on npm — check it when you wire the tools in, since the bundle's surface evolves faster than this page.

The tool surface

math

Arithmetic with a peek mode (read the result without committing to the conversation) and a reverse-polish-notation evaluator. Use it when the model needs deterministic numeric work mid-turn instead of guessing at sums in prose.

{
  "name": "math",
  "args": { "mode": "rpn", "expr": "3 4 + 2 *" }
}
// → { "result": 14 }
{
  "name": "math",
  "args": { "mode": "peek", "expr": "1.07 * 249.99" }
}
// → { "result": 267.4893, "committed": false }

datetime

Clock and date arithmetic. Four operations — now, parse, format, and diff — selected by the operation param. Every output is a string or number, never a JS Date, so the wire stays portable across the language-agnostic contract.

now returns the current UTC instant as an ISO-8601 string plus epoch milliseconds:

{
  "name": "datetime",
  "args": { "operation": "now" }
}
// → { "iso": "2026-06-06T16:14:22.117Z", "epochMs": 1781108062117, "timezone": "UTC" }

format renders an instant for a given IANA timezone. The formatted field is a wall-clock string (YYYY-MM-DDTHH:mm:ss) with no offset or Z suffix — it is local-to-the-zone, not a round-trippable ISO-8601 instant. The iso field alongside it is the original UTC instant if you need the portable form:

{
  "name": "datetime",
  "args": { "operation": "format", "input": "2026-06-06T16:14:22Z", "timezone": "America/Los_Angeles" }
}
// → { "iso": "2026-06-06T16:14:22.000Z", "timezone": "America/Los_Angeles", "formatted": "2026-06-06T09:14:22" }

diff returns the signed difference between two instants in a chosen unit (milliseconds, seconds, minutes, hours, or days; default milliseconds):

{
  "name": "datetime",
  "args": { "operation": "diff", "input": "2026-06-06T00:00:00Z", "other": "2026-06-20T00:00:00Z", "unit": "days" }
}
// → { "from": "2026-06-06T00:00:00.000Z", "to": "2026-06-20T00:00:00.000Z", "unit": "days", "value": 14 }

There is no shift operation — to add an interval, compute it host-side (or with the math tool) and parse the result.

scratchpad

A per-session key/value store the model can read and write across the tool calls of a session. Keyed by ToolContext.chatId, so entries persist across turns within the same session (D-BASE1d). Use it for intermediate workings the model would otherwise have to thread through prose between tool calls. The operation param selects the action (set / get / list / delete / clear); values are strings.

{ "name": "scratchpad", "args": { "operation": "set", "key": "note", "value": "remember this" } }
{ "name": "scratchpad", "args": { "operation": "get", "key": "note" } }
// → { "found": true, "value": "remember this" }

The store is per-session by design — it does not survive session boundaries. A model that wants cross-session persistence should write to a real tool that hits storage, not lean on scratchpad and be surprised when state is gone in a new session.

unit_convert

SI and imperial unit conversions. The tool rejects category mismatches (length to mass, currency to temperature) with a typed error rather than silently returning garbage.

{ "name": "unit_convert", "args": { "value": 5, "from": "km", "to": "mi" } }
// → { "value": 3.1068559611866697, "unit": "mi" }
{ "name": "unit_convert", "args": { "value": 5, "from": "km", "to": "kg" } }
// → error: { "code": "UNIT_CATEGORY_MISMATCH", "from": "length", "to": "mass" }

Regex and substring search over a text body the caller supplies. No external corpus and no network — the tool is for "find the line that mentions X in this document I'm already holding."

{
  "name": "text_search",
  "args": {
    "body": "...long document text...",
    "pattern": "section 4\\.\\d+",
    "mode": "regex"
  }
}
// → { "matches": [{ "line": 142, "text": "section 4.2" }, ...] }

json_query

Extract a value from a JSON document by path. The natural partner to http_json — fetch an API response, then pull the one field the model needs instead of feeding the whole document back into the prompt. Pure and network-free. Supports dotted keys, array indices (negative counts from the end), quoted keys, and a [*] wildcard that collects a field from every array element. It is not full JSONPath — no filters, no recursive descent — so the evaluator is single-pass and cannot ReDoS.

{ "name": "json_query", "args": { "json": { "data": { "items": [{ "id": 1 }, { "id": 2 }] } }, "path": "data.items[*].id" } }
// → { "value": [1, 2], "found": true, "path": "data.items[*].id" }

found reports false when a path segment is missing — that is a result, not an error.

memory

Durable, cross-session key/value memory. Where scratchpad forgets everything at the session boundary, memory persists — a value written in one session is readable in the next. Use it for stable facts: a user's preferences, a running profile, a decision made earlier. Values are scoped by namespace (pass a user or tenant id) so one caller's memory is never visible to another. Operations: set / get / list / delete / search.

{ "name": "memory", "args": { "operation": "set", "namespace": "user-42", "key": "preferred_units", "value": "metric" } }
{ "name": "memory", "args": { "operation": "get", "namespace": "user-42", "key": "preferred_units" } }
// → { "found": true, "namespace": "user-42", "key": "preferred_units", "value": "metric" }

The backend is pluggable. The default is an in-process store — durable across sessions, gone on restart. Inject a MemoryStore (a five-method async interface) to back it with a real database and get restart-durable memory:

import { createMemoryTool } from "@pleach/base-tools";

const tool = createMemoryTool({ store: myDatabaseBackedStore });

Writes can be approval-gated. A model that can silently persist a "learned" fact is a model you cannot audit. Set requireApprovalForWrites: true and every set / delete routes through an injected approveWrite gate before it commits; reads stay ungated. createGraduatingApproval implements the common flow — prompt the human for the first few writes, then let the agent write on its own once trust is established:

import { createMemoryTool, createGraduatingApproval } from "@pleach/base-tools";

const tool = createMemoryTool({
  store: myDatabaseBackedStore,
  requireApprovalForWrites: true,
  approveWrite: createGraduatingApproval({
    confirm: (req) => askTheHuman(req),  // your approval UI
    autonomousAfter: 3,                  // ask 3 times, then autonomous
  }),
});

A denied write does not count toward graduation, so the human stays in control until they have approved enough writes to trust the agent.

url_fetch (opt-in)

A guarded HTTP GET. Off by default; the host opts in by registering the policy plugin (see Safety policies bundled below). The fetcher enforces:

  • No localhost or loopback targets.
  • No private CIDR ranges (RFC 1918, link-local, etc.).
  • A configurable allowlist for the domains the agent is permitted to reach.

Returns the body and response headers. Don't reach for it before the host has decided which domains the agent is allowed to touch — the defaults are restrictive precisely because "fetch any URL the model emits" is rarely what production wants.

{ "name": "url_fetch", "args": { "url": "https://example.com/article" } }
// → { "status": 200, "headers": { "content-type": "text/html" }, "body": "..." }

web_fetch (opt-in)

Read a web page as clean text. url_fetch returns the raw body; for an HTML page that is a wall of markup the model cannot cheaply read. web_fetch GETs an allowlisted URL through the same SSRF-safe fetch core and reduces HTML to readable text — dropping scripts, styles, and tags — returning the page title plus the body text. Non-HTML bodies pass through untouched. Gated on the same allowlist as url_fetch.

{ "name": "web_fetch", "args": { "url": "https://example.com/article" } }
// → { "httpStatus": 200, "url": "...", "title": "...", "text": "Readable body text...", "kind": "html", "truncated": false }

web_search (opt-in)

Search the web for a query and return a ranked list of { title, url, snippet }. The other half of the search-then-read loop: web_search finds the sources, web_fetch reads the best hit. The package ships no baked-in API key — you inject a searchProvider (Tavily, Brave, SerpAPI, a self-hosted SearXNG). Without a provider the tool refuses every call. A no-key createDuckDuckGoProvider is bundled for the quickstart; production hosts inject a real API provider.

import { createWebSearchTool } from "@pleach/base-tools";

const tool = createWebSearchTool({ searchProvider: myTavilyProvider });
{ "name": "web_search", "args": { "query": "error budget SRE", "maxResults": 5 } }
// → { "query": "error budget SRE", "results": [{ "title": "...", "url": "...", "snippet": "..." }, ...] }

http_json and http_head (opt-in)

Two convenience network tools over the same allowlisted fetch core. http_json GETs a URL and parses the body as JSON (returning the parsed value, or the raw text plus a parseError when it is not JSON). http_head issues a real HEAD request and returns metadata only — status, content type, length, etag, last-modified — never the body. Both share the url_fetch allowlist and refuse without one.

recall (opt-in)

Query the session event log for what already happened — past tool calls, their outcomes, interrupts. Use it before re-running an expensive tool to check whether you already ran it and what it returned. Provider-neutral by injection: you supply a reader over whatever holds the log — a Supabase table on a server, a local JSONL or SQLite file on a personal machine. Without a reader the tool refuses.

{ "name": "recall", "args": { "toolName": "web_search", "limit": 5 } }
// → { "events": [{ "type": "tool.completed", "toolName": "web_search", "at": 1781108062117 }, ...] }

The package also ships summarizeToolOutcomes, a pure function that folds recalled events into per-tool { started, completed, failed, lastError } counts. Feed the summary back as a prompt hint so the agent stops repeating what already failed — turning the audit log into a learning signal, not just a record.

Personal-agent tools (store-backed, opt-in)

These turn a stateless assistant into an agent that grows with the user — it maintains an evolving identity, a model of the user, learned facts, a searchable history, reminders, and a task plan across sessions. All are opt-in, refuse-by-default, and user-scoped (namespaced by (orgId, userId)) so one user's self is never another's. Each needs an injected BaseStore; without one the writes refuse and the reads return empty.

You rarely wire these by hand: createPleachAgent from @pleach/core/quickstart turns the whole family on automatically when its db carries a durable memory store, keyed to the local user. What the agent writes is re-injected into its own system prompt on the next turn — a real self-modification loop, not just a log.

Self-model — self_view, revise_identity, set_user_profile, pin_preference

The "SOUL.md + USER.md" pattern, made durable. self_view reads the composed self (identity + user-model + pinned preferences). The three writers evolve it: revise_identity amends the agent's own persona, set_user_profile records what it knows about the user, and pin_preference pins a behaviour rule that is re-injected every turn. Because identity rides the cacheable prompt prefix, revisions take effect going forward — the agent you talk to tomorrow is shaped by what it learned today.

{ "name": "pin_preference", "args": { "preference": "Always include a runnable example." } }
{ "name": "set_user_profile", "args": { "content": "Prefers TypeScript, no semicolons.", "mode": "append" } }

A bundled safety policy (self-model.guard-self-writes) keeps the agent from ever editing away its own safety, ethics, or honesty — those are not part of the editable self.

Meta-learning — recall_facts, correct_fact, forget_fact, prune_memory, detect_conflicts, reflect

The typed, confidence-scored learning substrate, exposed to the agent itself. recall_facts reads what it has learned about the user (with categories + confidence); correct_fact pins a corrected belief (confidence 1.0, user-verified, decay-exempt); forget_fact drops one; prune_memory clears stale/low-confidence facts; detect_conflicts surfaces contradictions to reconcile; reflect reads the agent's own success/error rate and improving-or-degrading trend.

{ "name": "recall_facts", "args": { "minConfidence": 0.7 } }
{ "name": "correct_fact", "args": { "fact_key": "pref-42", "new_content": "Prefers Python, not TypeScript." } }

skill_search discovers reusable skills by intent or free text (the discovery half of skill_view / create_skill). db_lookup reads the agent's own durable namespaces (facts, self, skills, memory), bounded to its own data. session_search is full-text recall over past conversation — "have we discussed this?" — relevance-ranked; back it with a reader over a text index (the SQLite store's FTS5 searchEvents).

{ "name": "session_search", "args": { "query": "docking results egfr", "limit": 5 } }

Scheduling — schedule, list_schedules, cancel_schedule (opt-in)

Record a future task or proactive check-in for the agent itself (schedule, with an optional every recurrence like "1d"), review pending ones (list_schedules), or drop one (cancel_schedule). The package owns the durable intent; a host supplies the clock — read due tasks with readScheduledTasks and re-enter the agent with each task's prompt.

{ "name": "schedule", "args": { "when": "2026-08-05T09:00:00Z", "prompt": "Follow up on the EGFR run.", "every": "1d" } }

Task tracker — todo_write, todo_read

A visible, status-tracked checklist for a multi-step job — the Claude Code TodoWrite pattern. todo_write replaces the whole list (mark one item in_progress, flip to completed as you go); todo_read resumes it, even in a later session.

{ "name": "todo_write", "args": { "todos": [
  { "content": "Read the config", "status": "completed" },
  { "content": "Add the field", "status": "in_progress" },
  { "content": "Write a test", "status": "pending" }
] } }

Capability-gated tools (opt-in)

Three tools that touch code execution, the human, or the filesystem. The package ships the tool contract and a permission gate, never a bundled runtime — running untrusted code or opening the filesystem is the host's trusted runtime's job. Each refuses by default until the host injects the capability. That keeps @pleach/core free of a large attack surface it should not carry.

code_exec

Execute code in a sandbox and return its stdout / stderr. You inject a runner — your own sandbox (@pleach/coding-agent, Vercel Sandbox, a Firecracker microVM). An optional language allowlist and a per-call timeout bound what it runs. Without a runner the tool refuses.

import { createCodeExecTool } from "@pleach/base-tools";

const tool = createCodeExecTool({ runner: mySandbox, allowedLanguages: ["python"] });

ask_user

Pause and ask the human a clarifying question. @pleach/core has a richer interrupt system; this is the portable surface any host can wire, including one not using the interrupt machinery — a CLI reading stdin, a bot posting a question. You inject an ask handler that bridges to whatever asks the human. Without it the tool refuses.

fs_read, fs_write, fs_list

Read, write, and list files on a sandboxed filesystem. You inject a FileSystemAdapter plus an allowedRoots allowlist; every path is confined under an allowed root — with .. escapes and null bytes rejected — before the adapter is ever called. The filesystem is read-only by default: fs_write refuses until the host opts into mode: "read-write", the filesystem analogue of url_fetch's allowHttp.

import { createFileSystemTools } from "@pleach/base-tools";

const { fsReadTool, fsWriteTool, fsListTool } = createFileSystemTools({
  adapter: myFsAdapter,
  allowedRoots: ["/workspace"],
  mode: "read-write",
});

Given a markdown body, returns the extracted-link envelope. Each link carries the anchor text, the URL, the recognized shape (inline / reference / autolink / bare), and the offset into the input where the match started. Useful for "what did the model cite" auditing on top of url_fetch output, or for inspecting markdown the model itself produced.

import { extractMarkdownLinks } from "@pleach/base-tools";
import type {
  MarkdownLink,
  MarkdownLinkExtractionResult,
} from "@pleach/base-tools";

const result: MarkdownLinkExtractionResult = extractMarkdownLinks(body);
// → {
//     links: [
//       { text: "the spec",   url: "https://example.com/spec",   kind: "inline", index: 42 },
//       { text: "issue #142", url: "https://github.com/.../142", kind: "inline", index: 118 },
//     ],
//     truncated: false,
//   }

truncated flips to true when the input exceeds MAX_LINKS (default 1000) and the returned array was capped. Input larger than MAX_INPUT_BYTES (default 1 MB) throws — chunk the input first if you're processing larger bodies.

This is a utility, not a tool — call it from host code that's processing tool output, not from a tool the model invokes.

Safety policies bundled

The package contributes safety policies via the standard HarnessPlugin contract. The headline policy is the url_fetch private-network guard described above; the package ships it as a contributeSafetyPolicies entry on its plugin export so that registering the plugin opts the host into the guard.

import { SessionRuntime } from "@pleach/core";
import { createBaseToolsPlugin } from "@pleach/base-tools";

const runtime = new SessionRuntime({
  storage: myStorage,
  plugins: [
    createBaseToolsPlugin({
      include: ["math", "url_fetch"],
      urlFetch: { allowedHostnames: ["example.com"] },
    }),
  ],
  userId: "user_123",
});

The host stays in control: don't register the plugin and url_fetch isn't on the surface at all. Register it with a tight allowlist and the model only reaches the domains the host approved. See Safety for the policy contract the bundled entries implement against.

The four named policies

contributeSafetyPolicies() returns four SafetyContribution constants, each also importable by name from @pleach/base-tools (and @pleach/core/base-tools). Registration is not activation — each policy is inert until the operator opts in via enabledSafetyPolicies: [...] at SessionRuntime construction.

ConstantPolicy idKind
NO_EVAL_POLICYbase-tools.no-evalrefusal — reject arbitrary-code-execution requests; base-tools ships no eval surface
NO_NETWORK_BY_DEFAULT_POLICYbase-tools.no-network-by-defaultadvisory — declare that the base toolset performs no network I/O
BOUNDED_RESOURCE_USAGE_POLICYbase-tools.bounded-resource-usageadvisory — surface the explicit caps each tool enforces
INPUT_VALIDATION_POLICYbase-tools.input-validationadvisory — encourage shape validation before invocation

The frozen BASE_TOOLS_SAFETY_POLICIES array holds all four in registration order. A host that does provide eval or network capability should not enable base-tools.no-eval / base-tools.no-network-by-default — the prose would contradict its own tool catalog.

Registration pattern

The tools register through the plugin's contributeTools hook, the same way any HarnessPlugin adds to the tool registry. The package likely exposes a one-call helper that returns the configured plugin — check the package README for the exact name. The narrative shape is:

Use createBaseToolsPlugin(...) to configure the set; baseToolsPlugin is the zero-config singleton for the quickstart. The zero-config default includes the seven tools that need no injected capability (math, datetime, scratchpad, unit_convert, text_search, json_query, memory); every capability-gated tool is opt-in via include.

import { SessionRuntime } from "@pleach/core";
import { createBaseToolsPlugin } from "@pleach/base-tools";

const runtime = new SessionRuntime({
  storage: myStorage,
  plugins: [
    createBaseToolsPlugin({
      // The seven zero-config tools plus two opt-in ones:
      include: [
        "math", "datetime", "scratchpad", "unit_convert", "text_search",
        "json_query", "memory", "web_fetch", "web_search",
      ],
      // Network tools stay off until an allowlist is supplied:
      urlFetch: { allowedHostnames: ["docs.example.com"] },
      webSearch: { searchProvider: myTavilyProvider },
    }),
  ],
  userId: "user_123",
});

const session = await runtime.createSession({
  tools: { enabled: ["math", "datetime", "scratchpad"] },
});

The session's tools.enabled array selects which of the registered tools that particular session can see — registration and exposure are separate concerns, so the same runtime can serve a numeric agent and a search agent without each one inheriting the other's surface.

Position vs @pleach/tools

PackageWhat it shipsReach for it when
@pleach/toolsdefineTool primitive, tool-loader contracts, batching strategy hintsYou're writing your own tools
@pleach/base-toolsPre-built tools — zero-config (math, datetime, scratchpad, unit_convert, text_search, json_query, memory), opt-in network/agentic (web_search, web_fetch, http_json, http_head, url_fetch, code_exec, ask_user, fs_read/fs_write/fs_list, recall), and store-backed personal-agent tools (self-model, meta-learning, skill_search/db_lookup/session_search, scheduling, todo_write/todo_read) — plus the safety policies that guard themYou want a sane default toolbelt without re-implementing the common cases

Both compose. A typical host installs @pleach/tools to write its domain-specific tools and @pleach/base-tools to cover the generic surface; the two plugins register side-by-side and contribute to the same tool registry.

Where to go next

On this page