npm.io
0.11.0 • Published 7h ago

armorer

Licence
MIT
Version
0.11.0
Deps
0
Size
2.5 MB
Vulns
0
Weekly
0
Stars
1

Armorer

armorer is the Agent Bureau tool layer. It provides type-safe tool definitions, validated execution, toolbox registries, provider adapters, middleware, MCP integration, and testing utilities.

Table of Contents

Overview

Toolbox turns tool calling into a structured, observable, and searchable workflow. Define schemas once, validate at runtime, and export tools to popular providers without rewriting adapters.

What It Does

  • Creates validated tools with createTool().
  • Groups tools into immutable registries with createToolbox().
  • Executes tool calls with runtime validation, middleware, events, policies, idempotency, and streaming support.
  • Converts tool definitions and tool results for OpenAI, Anthropic, Gemini, OpenAI Agents SDK, and MCP.
  • Supports registry querying, semantic search, inspection, truncation, and testing utilities.

How It Works

A tool combines metadata, an input schema, and an execute function. A toolbox registers one or more tools, validates incoming calls, runs middleware and policy hooks, executes the matching tool, and emits typed lifecycle events. Provider adapters materialize the same toolbox into provider-specific tool declarations and parse provider tool calls back into the shared interoperability contract.

Project Role

armorer is the action layer for Agent Bureau. operative uses it to execute tool calls during an agent run and uses its provider adapters when sending tools to providers, skills and memory expose their capabilities as toolboxes, and gateway surfaces the composed tool set through API and UI state.

Features

  • Zod-powered schema validation with TypeScript inference
  • Central tool registry with execution, policy, and event hooks
  • Query helpers with text, tag, schema, and metadata filters
  • Semantic search with vector embeddings (OpenAI, Pinecone, etc.)
  • Provider adapters for OpenAI, Anthropic, and Gemini
  • Tool composition utilities (pipe/bind/when/parallel/retry)
  • OpenTelemetry Instrumentation: Native tracing for agentic loops
  • Built-in Middleware: Caching, Rate Limiting, and Timeouts
  • Testing Utilities: Mock tools and test registries for easy verification
  • MCP server integration for exposing tools over MCP
  • OpenAI Agents SDK integration with tool gating and MCP support for Claude Agent SDK
  • Concurrency controls and execution tracing hooks
  • Pre-configured search tool for semantic tool discovery in agentic workflows

Package Structure

Toolbox is organized into focused submodules so you can import only what you need:

Core Modules
armorer (Main Entry Point)

The primary API for creating and managing tools:

import { createToolbox, createTool, isTool } from 'armorer';

Exports: createToolbox, createTool, createToolCall, combineToolboxes (plus deprecated alias combineToolbox), lazy, withContext, isTool, isToolbox, createMiddleware, provider import helpers on createToolbox, and all core types.

armorer/utilities

Composition and utility functions:

import { pipe, parallel, retry, when } from 'armorer/utilities';

Exports: Everything from main entry point plus pipe, bind, parallel, retry, when, tap, preprocess, postprocess, PipelineError, error utilities, and composition types.

armorer/query

Query helpers and predicates for filtering tools:

import { queryTools, textMatches, tagsMatchAll, schemaMatches } from 'armorer/query';

Exports: queryTools, reindexSearchIndex, textMatches, tagsMatchAll, tagsMatchAny, tagsMatchNone, schemaMatches, schemaHasKeys, and related types.

armorer/inspect

Tool and registry inspection utilities:

import { inspectTool, inspectRegistry } from 'armorer/inspect';

Exports: inspectTool, inspectRegistry, extractSchemaSummary, extractMetadataFlags, and Zod schemas for inspection results.

Provider Adapters
armorer/adapters/openai

OpenAI Chat Completions API format:

import {
  formatOpenAIToolResults,
  formatOpenAIToolResultsAsync,
  fromOpenAITools,
  parseOpenAIToolCalls,
  toOpenAITools,
} from 'armorer/adapters/openai';
armorer/adapters/anthropic

Anthropic Messages API format:

import {
  formatAnthropicToolResults,
  fromAnthropicTools,
  parseAnthropicToolCalls,
  toAnthropicTools,
} from 'armorer/adapters/anthropic';
armorer/adapters/gemini

Google Gemini API format:

import {
  formatGeminiToolResults,
  fromGeminiTools,
  parseGeminiToolCalls,
  toGeminiTools,
} from 'armorer/adapters/gemini';

Toolboxes also expose lazy provider exporters, and createToolbox exposes matching lazy provider import helpers:

const openAITools = await toolbox.toOpenAITools();
const importedToolbox = await createToolbox.fromOpenAITools(openAITools, {
  getTool(configuration) {
    return async (params) => {
      throw new Error(`Add execute for ${configuration.name}`);
    };
  },
});

Use the toolbox methods when your tools define runtime availability hooks. toolbox.toOpenAITools(), toolbox.toAnthropicTools(), toolbox.toGeminiTools(), and toolbox.toProvider(...) evaluate availability against the toolbox context and omit unavailable tools from the provider manifest.

armorer/truncation

Truncation utilities for tool results:

import { truncateToolResultContent, containsBase64Data } from 'armorer/truncation';

Exports: truncateToolResultContent, truncateToolResultContentStructured, truncateText, safeSlice, createTruncatingAsyncIterable, containsBase64Data, stripBase64Data, isHighSurrogate, isLowSurrogate, DEFAULT_MAX_CHARACTERS, DEFAULT_ERROR_MAX_CHARACTERS, and types TruncationOptions, ToolResultTruncationOptions, StructuredToolResultTruncationOptions, and StructuredToolResultTruncation.

armorer/idempotency

Persistent idempotency helpers for at-least-once executors:

import { createToolResultCache, fullInputKey, withToolboxIdempotency } from 'armorer/idempotency';

Exports: createToolResultCache, withIdempotency, withToolboxIdempotency, fullInputKey, fieldKey, compositeKey, namespacedKey, and idempotency cache/result types.

Infrastructure
armorer/instrumentation

OpenTelemetry tracing:

import { instrument } from 'armorer/instrumentation';
armorer/middleware

Standard middleware (caching, rate limiting, timeouts, truncation):

import {
  createCacheMiddleware,
  createRateLimitMiddleware,
  createTimeoutMiddleware,
  createTruncationMiddleware,
} from 'armorer/middleware';
armorer/test

Testing utilities:

import { createMockTool, createTestRegistry } from 'armorer/test';
Integrations
armorer/mcp (or armorer/integrations/mcp)

Model Context Protocol server integration:

import { createMCP, toMcpTools, fromMcpTools } from 'armorer/mcp';
armorer/adapters/open-ai/agents

OpenAI Agents SDK integration with tool gating:

import { toOpenAIAgentTools, createOpenAIToolGate } from 'armorer/adapters/open-ai/agents';
Other Utilities
armorer/tools

Pre-built tools (search, etc.):

import { createSearchTool } from 'armorer/tools';
armorer/utilities

Composition utilities (re-exported from armorer/utilities):

import { pipe, bind, parallel } from 'armorer/utilities';

Quick Start

import { createToolbox, createTool } from 'armorer';
import { z } from 'zod';

const addNumbers = createTool({
  name: 'add-numbers',
  description: 'Add two numbers together',
  input: z.object({
    a: z.number(),
    b: z.number(),
  }),
  tags: ['math', 'calculator'],
  async execute({ a, b }) {
    return a + b;
  },
});

const toolbox = createToolbox([addNumbers]);

const toolCall = await toolbox.execute({
  id: 'call-123',
  name: 'add-numbers',
  arguments: { a: 5, b: 3 },
});

console.log(toolCall.result); // 8

Immutable Toolbox Composition

Compose toolboxes without mutating existing instances.

import { createToolbox, combineToolboxes } from 'armorer';

const base = createToolbox([mathTool], {
  context: { region: 'us-east-1' },
});

const extended = base.extend(stringTool);
// `base` is unchanged, `extended` has both tools.

const adminTools = createToolbox([auditTool], {
  context: { role: 'admin' },
});

const merged = base.extend(adminTools);
// Context is shallow merged, last toolbox wins:
// merged context => { region: 'us-east-1', role: 'admin' }

const combined = combineToolboxes(base, adminTools);
// Same merge rules, useful when combining many toolboxes at once.

Safety and Policy

Use policy hooks to block or gate risky actions before execution.

Batch Execution

Execute multiple tools in parallel or sequentially with global controls.

const results = await toolbox.execute([call1, call2, call3], {
  concurrency: 5, // Global concurrency limit
  mode: 'parallel', // 'parallel' | 'sequential'
  errorMode: 'collect', // 'collect' (default) | 'failFast'
});

Approval Flows

Policies can pause execution for human approval or input. Return allow: false with status: 'needs_approval' to pause a call; the status selects the approval action while allow supplies the required policy decision.

const toolbox = createToolbox([], {
  // Use the same secret anywhere pending approvals may be resumed.
  approvalSecret: process.env.ARMORER_APPROVAL_SECRET,
  policy: {
    async beforeExecute(context) {
      if (context.metadata?.sensitive) {
        return {
          allow: false,
          status: 'needs_approval',
          reason: 'Sensitive action requires confirmation',
        };
      }
      return { allow: true };
    },
  },
});

const result = await toolbox.execute(sensitiveCall);
if (result.outcome === 'action_required') {
  // Persist the descriptor and show the approval UI to the user.
  await durableStore.set(result.pendingApproval!.callId, result.pendingApproval);
}

// Later, possibly in another process, rebuild the same toolbox and resume.
const approved = await durableStore.get('tool-call-id');
const resumed = await toolbox.resumeApproval(approved);

if (resumed.executedArgumentsEdited) {
  // Record both the proposed and executed arguments in your transcript.
}

pendingApproval is JSON-serializable and includes callId, toolName, validated arguments, the requested action, reason, and tool metadata. When the toolbox has an approvalSecret, the descriptor also includes an approvalToken and can be treated as SignedPendingToolApproval for resumeApproval(). Use the same approvalSecret anywhere the approval may be resumed. Without approvalSecret, pending approvals are unsigned and cannot be resumed through resumeApproval(). resumeApproval() verifies the token, reconstructs the original call id, re-validates the original or edited arguments, and re-runs policy against those arguments. The granted approval only satisfies the original approval prompt when the resumed arguments match the proposed arguments; edited arguments must be allowed by policy. The result sets executedArgumentsEdited when the resumed arguments differ from the proposed arguments.

Agent Integration

Toolbox provides helpers to integrate with large language model providers like OpenAI.

import {
  formatOpenAIToolResults,
  formatOpenAIToolResultsAsync,
  parseOpenAIToolCalls,
  toOpenAITools,
} from 'armorer/adapters/openai';

// 1. Export tools
const tools = toOpenAITools(toolbox);

// 2. Call model
const completion = await openai.chat.completions.create({ tools, ... });

// 3. Parse and execute
const toolCalls = parseOpenAIToolCalls(completion.choices[0].message.tool_calls);
const results = await toolbox.execute(toolCalls);

// 4. Format results
const messages = formatOpenAIToolResults(results);

// Use async formatter when any tool call uses { stream: true }
const streamingMessages = await formatOpenAIToolResultsAsync(results);

If you want the root package to stay adapter-light until you need it, use the lazy toolbox methods instead:

const tools = await toolbox.toOpenAITools();
const imported = await createToolbox.fromOpenAITools(tools, {
  getTool(configuration) {
    return async (params) => loadExecute(configuration.name, params);
  },
});

Using with Conversationalist

Use armorer for tool schemas, provider tool definitions, tool-call parsing, and execution. Use conversationalist for the persistent conversation state and provider message history.

import {
  appendToolCalls,
  appendToolResultsAsync,
  appendUserMessage,
  createConversationHistory,
} from 'conversationalist/conversation';
import { toOpenAIMessagesGrouped } from 'conversationalist/adapters/openai';
import { createToolbox } from 'armorer';
import { parseOpenAIToolCalls, toOpenAITools } from 'armorer/adapters/openai';

let conversation = createConversationHistory({ title: 'Weather' });
conversation = appendUserMessage(conversation, 'What is the weather in Denver?');

const tools = toOpenAITools(toolbox);
const messages = toOpenAIMessagesGrouped(conversation);
const completion = await openai.chat.completions.create({ model: 'gpt-4o', messages, tools });

const toolCalls = parseOpenAIToolCalls(completion.choices[0]?.message?.tool_calls);
conversation = appendToolCalls(conversation, toolCalls);

const results = await toolbox.execute(toolCalls, { stream: true });
conversation = await appendToolResultsAsync(conversation, results);

See Using armorer with conversationalist for complete OpenAI, Anthropic, and Gemini examples.

Observability (OpenTelemetry)

Native instrumentation for distributed tracing.

import { createToolbox } from 'armorer';
import { instrument } from 'armorer/instrumentation';
import { context, trace } from '@opentelemetry/api';

const toolbox = createToolbox();
instrument(toolbox); // Auto-wires all tool calls to OpenTelemetry spans

const tracer = trace.getTracer('worker');
await tracer.startActiveSpan('temporal.activity', async (activitySpan) => {
  await toolbox.execute(
    { id: 'lookup-account', name: 'lookupAccount', arguments: { accountId: 'acct_123' } },
    {
      parentContext: trace.setSpan(context.active(), activitySpan),
      spanLinks: [{ context: activitySpan.spanContext() }],
    },
  );
});

Pass parentContext to nest tool spans under an existing OpenTelemetry context. Pass links to attach span links when the orchestrator prefers linked traces over a direct parent-child relationship.

OTel GenAI semantic conventions

The tool span (execute_tool {name}, kind INTERNAL) follows the OTel GenAI "Execute tool" span convention. See the pinned-version note and full mapping table in operative's README — armorer's tool span is one row of that table.

Attribute Source
gen_ai.operation.name Always execute_tool
gen_ai.tool.name tool.identity.name
gen_ai.tool.call.id call.id
gen_ai.tool.call.arguments call.arguments, stringified
gen_ai.tool.call.result Result on success, stringified
gen_ai.tool.description tool.description, when set
error.type errorCategory (or status) when the call errored or was denied

Non-standard fields (duration, digests, cancellation reason, internal status) are namespaced under armorer.tool.* rather than gen_ai.*, since the conventions do not define them and squatting the reserved gen_ai.* vocabulary would confuse a generic OTel GenAI backend.

Middleware

Batteries-included middleware for production needs.

import { createToolbox } from 'armorer';
import {
  createCacheMiddleware,
  createRateLimitMiddleware,
  createTruncationMiddleware,
  createUntrustedOutputFencingMiddleware,
} from 'armorer/middleware';

const toolbox = createToolbox([], {
  middleware: [
    createCacheMiddleware({ ttlMs: 60000 }),
    createRateLimitMiddleware({ limit: 100, windowMs: 60000 }),
    createUntrustedOutputFencingMiddleware(),
    createTruncationMiddleware({ maxCharacters: 2000 }),
  ],
});
Truncation

Prevent oversized tool results from blowing up context windows. The truncation utilities safely handle UTF-16 surrogate pairs and strip base64 data. When a tool returns a streaming result (an object with stream or result fields containing an AsyncIterable), the middleware wraps the streams so chunks are yielded until the character limit is reached, then a truncation marker is emitted and iteration stops.

import { truncateToolResultContent } from 'armorer/truncation';
import { createTruncationMiddleware } from 'armorer/middleware';

// Standalone usage
const truncated = truncateToolResultContent(longResult, {
  maxCharacters: 4000,
  isError: false,
});

// As middleware (handles both string results and streaming results)
const toolbox = createToolbox(tools, {
  middleware: [createTruncationMiddleware({ maxCharacters: 4000 })],
});

For large tool outputs that are stored separately, use the structured head-and-tail helper:

import { truncateToolResultContentStructured } from 'armorer/truncation';

const excerpt = truncateToolResultContentStructured(toolOutput, {
  maxBytes: 8000,
});

if (excerpt.truncated) {
  console.log(`${excerpt.head}\n... omitted ${excerpt.omittedBytes} bytes ...\n${excerpt.tail}`);
}

The structured result is { head, tail, originalSize, omittedBytes, truncated }. Byte counts use UTF-8 size after base64 payload replacement, and the excerpts avoid splitting UTF-16 surrogate pairs.

Untrusted Tool Output

Tools that return third-party text can carry prompt-injection instructions inside otherwise useful data. Mark those tools with risk.untrustedOutput: true, then add the fencing middleware before truncation so the model receives a clear data boundary.

import { createToolbox, createTool } from 'armorer';
import { createUntrustedOutputFencingMiddleware } from 'armorer/middleware';
import { queryTools } from 'armorer/query';
import { z } from 'zod';

const fetchPage = createTool({
  name: 'web.fetch',
  description: 'Fetch a web page',
  input: z.object({ url: z.string().url() }),
  risk: { untrustedOutput: true },
  async execute({ url }) {
    return await fetch(url).then((response) => response.text());
  },
});

const toolbox = createToolbox([fetchPage], {
  middleware: [createUntrustedOutputFencingMiddleware()],
});

const untrustedTools = queryTools(toolbox, {
  risk: { untrustedOutput: true },
});

The middleware leaves unflagged tools unchanged. For flagged tools, string results and object content fields are wrapped in configurable delimiters with a preamble that tells the model to treat the fenced text as data, not instructions. The risk flag is also queryable, so consumers can write tests that every web, browser, or document-ingestion tool carries risk.untrustedOutput: true.

Idempotency

Use armorer/idempotency when a tool might be retried by an at-least-once executor. The cache records three outcomes:

  • fresh: this process executed the tool and recorded the result.
  • deduped: a completed result already existed and was returned.
  • unknown-outcome: execution started earlier, but no result was recorded.
import { createToolbox } from 'armorer';
import { createToolResultCache, withToolboxIdempotency } from 'armorer/idempotency';

const cache = createToolResultCache({ store: durableKeyValueStore });
const toolbox = createToolbox([chargeCardTool]);
const idempotentToolbox = withToolboxIdempotency(toolbox, { cache });

const result = await idempotentToolbox.execute(
  {
    id: 'provider-call-1',
    name: 'charge-card',
    arguments: { cents: 2500 },
  },
  {
    idempotencyKey: temporalToolCallId,
  },
);

if (result.idempotency?.outcome === 'unknown-outcome') {
  // Do not replay blindly. Ask for review before retrying.
}

After human review, retry an unknown outcome explicitly:

await idempotentToolbox.execute(call, {
  idempotencyKey: temporalToolCallId,
  retryUnknownOutcome: true,
});

If you do not pass idempotencyKey, withToolboxIdempotency() uses each tool's configured idempotencyKey function. Tools without an idempotencyKey are not deduped by default; set requireExplicitKey: false to use fullInputKey for those tools. Caller-supplied keys are useful when an orchestrator already mints a stable key per provider tool_call_id and needs retries to reuse that exact key. Armorer scopes the cache entry by tool name, so the same external key cannot dedupe two different tools into one result.

The default createToolResultCache() serializes claimStarted() calls for the same key within a cache instance. For durable stores shared across processes, implement ToolResultCache.claimStarted() with compare-and-set semantics. Without that method, Armorer keeps compatibility with the original completed-result cache shape and falls back to a read-then-mark path when markStarted() is available. That fallback protects retries after a crashed execution, but only an atomic cache backend can prevent two concurrent processes from claiming the same new key at exactly the same time.

Fuzzy Tool Name Resolution

LLMs sometimes mangle tool names (wrong case, dots instead of hyphens). Enable resolution to auto-correct:

const toolbox = createToolbox(tools, {
  resolution: true,
});

toolbox.addEventListener('name-resolved', (event) => {
  console.log(
    `Resolved ${event.detail.originalName}${event.detail.resolvedName} (${event.detail.tier})`,
  );
});

Resolution tiers (in order): exact → case-insensitive → normalized (dot/slash/underscore → hyphen) → suffix (last segment). Ambiguous matches return not-found for safety.

Loop Detection

Catch stuck models that repeat the same tool call in a loop:

const toolbox = createToolbox(tools, {
  loopDetection: true, // or { warningThreshold: 5, blockThreshold: 10 }
});

toolbox.addEventListener('loop-warning', (event) => {
  console.warn(event.detail.message);
});

toolbox.addEventListener('loop-blocked', (event) => {
  console.error(event.detail.message);
  // Tool call was blocked and returned an error result
});

Detectors: simple repeat (same call N times) and ping-pong (alternating between two calls).

Testing

Utilities for testing tools and agent logic.

import { createMockTool, createTestRegistry } from 'armorer/test';

const mock = createMockTool({ name: 'weather' });
mock.mockResolve({ temp: 72 });

const toolbox = createTestRegistry();
toolbox.register(mock);

await toolbox.execute({ name: 'weather', arguments: {} });
console.log(toolbox.history[0].call.name); // 'weather'

Safety, Policy, and Metadata

Toolbox supports registry-level policy hooks and per-tool policy for centralized guardrails. You can also tag tools as mutating or read-only and enforce those tags at the registry. See the Registry documentation for details on querying, searching, and middleware.

import { createToolbox, createTool } from 'armorer';
import { z } from 'zod';

const toolbox = createToolbox([], {
  readOnly: true,
  policy: {
    beforeExecute({ toolName, metadata }) {
      if (metadata?.mutates) {
        return { allow: false, reason: `${toolName} is mutating` };
      }
    },
  },
  telemetry: true,
});

const writeFile = createTool({
  name: 'fs.write',
  description: 'Write a file',
  input: z.object({ path: z.string(), content: z.string() }),
  metadata: { mutates: true },
  async execute() {
    return { ok: true };
  },
});

toolbox.register(writeFile);

Metadata keys with built-in enforcement:

  • metadata.mutates: true marks a tool as mutating
  • metadata.readOnly: true marks a tool as read-only
  • metadata.dangerous: true marks a tool as dangerous
  • metadata.concurrency: number sets a per-tool concurrency limit

Risk flags with built-in query and tag support:

  • risk.untrustedOutput: true marks a tool whose output may contain third-party instructions and adds the untrusted-output tag

Registry options for enforcement:

  • readOnly: true denies mutating tools automatically
  • allowMutation: false denies mutating tools automatically
  • allowDangerous: false denies dangerous tools automatically

Execution tracing events (opt-in via telemetry: true):

  • tool.started with startedAt
  • tool.finished with status and durationMs

Per-tool concurrency:

createTool({
  name: 'git.status',
  description: 'status',
  metadata: { concurrency: 1 },
  input: z.object({}),
  async execute() {
    return { ok: true };
  },
});

Creating Tools

Overview

Define tools with Zod schemas, validation, and typed execution contexts. For advanced patterns like chaining tools together, see Tool Composition.

Basic Tool
const greetUser = createTool({
  name: 'greet-user',
  description: 'Greet a user by name',
  input: z.object({
    name: z.string(),
    formal: z.boolean().optional(),
  }),
  async execute({ name, formal }) {
    return formal ? `Good day, ${name}.` : `Hey ${name}!`;
  },
});

Tools are callable. await tool(params) and await tool.execute(params) are equivalent. If you need a ToolResult object instead of throwing on errors, use tool.execute(toolCall) or tool.executeWith(...).

executeWith(...) lets you supply params plus callId, timeout (milliseconds), signal, and stream in a single call, returning a ToolResult instead of throwing. rawExecute(...) invokes the underlying implementation with a full ToolContext when you need precise control over dispatch/meta or to bypass the ToolCall wrapper.

Tool schemas must be object schemas (z.object(...) or a plain object shape). Tool calls always pass a JSON object for arguments, so wrap primitives inside an object (for example, z.object({ value: z.number() })).

Runtime Availability

Use availability when a tool can only run in the current platform or runtime environment. The hook is evaluated lazily against the toolbox context, so boot-time checks such as process.platform, an optional local binary probe, or a service health flag can decide whether the tool should be shown to a model.

const toolbox = createToolbox([], {
  context: {
    platform: process.platform,
    ripgrepAvailable: await hasCommand('rg'),
  },
});

const searchFiles = createTool({
  name: 'search-files',
  description: 'Search local files with ripgrep',
  input: z.object({ query: z.string() }),
  availability(context) {
    return context['ripgrepAvailable'] === true;
  },
  async execute({ query }) {
    return runRipgrep(query);
  },
});

await toolbox.getAvailable() returns only tools whose hook passes. The toolbox provider exporters use the same filter, so unavailable tools are excluded from model manifests. If a caller still tries to execute an unavailable tool by name, toolbox.execute(...) returns a structured ToolError with category unavailable and code TOOL_UNAVAILABLE; it does not call the tool implementation.

Availability is not secrets configuration. A tool with an optional API key can still be available without the key if the key only raises limits or unlocks a higher quota. Use availability for platform/runtime capability, not for hiding keyless tools that can still run.

You can use isTool(obj) to check if an object is a tool:

import { isTool, createTool } from 'armorer';

const tool = createTool({ ... });
if (isTool(tool)) {
  // TypeScript knows tool is ToolboxTool here
  console.log(tool.name);
}
Creating and Registering in One Step

You can create a tool and register it with a toolbox in one step by passing the toolbox as the second argument:

const toolbox = createToolbox([], {
  context: { userId: 'user-123', apiKey: 'secret' },
});

const tool = createTool(
  {
    name: 'my-tool',
    description: 'A tool with toolbox context',
    input: z.object({ input: z.string() }),
    async execute({ input }, context) {
      // context includes toolbox.context automatically
      console.log('User:', context.userId);
      return input.toUpperCase();
    },
  },
  toolbox, // Automatically registers the tool
);
Tool Without Inputs

If your tool accepts no input arguments, omit input (it defaults to z.object({})):

const healthCheck = createTool({
  name: 'health-check',
  description: 'Verify service is alive',
  async execute() {
    return 'ok';
  },
});
Tool with Metadata

Metadata is a lightweight, out-of-band descriptor for things that should not be part of the tool's input schema. It is useful for discovery and routing (filter/query by tier, cost, capabilities, auth requirements), for UI grouping, or for analytics and policy checks without changing the tool signature.

const fetchWeather = createTool({
  name: 'fetch-weather',
  description: 'Get current weather for a location',
  input: z.object({
    city: z.string(),
    units: z.enum(['celsius', 'fahrenheit']).optional(),
  }),
  tags: ['weather', 'api', 'external'],
  metadata: {
    requiresAuth: true,
    rateLimit: 100,
    capabilities: ['read'],
  },
  async execute({ city, units = 'celsius' }) {
    // ... fetch weather data
    return { temp: 22, conditions: 'sunny' };
  },
});
Tool with Context

Use withContext to inject shared context into tools:

const createToolWithContext = withContext({ userId: 'user-123', apiKey: 'secret' });

const userTool = createToolWithContext({
  name: 'get-user-data',
  description: 'Fetch user data',
  input: z.object({}),
  async execute(_params, context) {
    // Access context.userId and context.apiKey
    return { userId: context.userId };
  },
});
Lazy-Loaded Execute Functions

You can supply execute as a promise that resolves to a function. To avoid import() starting immediately, wrap the dynamic import with lazy so it only loads on first execution:

import { lazy } from 'armorer/lazy';

const heavyTool = createTool({
  name: 'heavy-tool',
  description: 'Runs an expensive workflow',
  input: z.object({ input: z.string() }),
  execute: lazy(() => import('./tools/heavy-tool').then((mod) => mod.execute)),
});

If the promise rejects or resolves to a non-function, tool.execute(toolCall) returns a ToolResult with error set, and tool.execute(params) or calling the tool directly throws an Error with the same message.

Tool Events

Listen to tool execution lifecycle events:

const tool = createTool({
  name: 'my-tool',
  description: 'A tool with events',
  input: z.object({ input: z.string() }),
  async execute({ input }, { dispatch }) {
    dispatch({ type: 'progress', detail: { percent: 50, message: 'Processing...' } });
    return input.toUpperCase();
  },
});

tool.addEventListener('execute-start', (event) => {
  console.log('Starting:', event.detail.params);
});

tool.addEventListener('execute-success', (event) => {
  console.log('Result:', event.detail.result);
});

tool.addEventListener('execute-error', (event) => {
  console.error('Error:', event.detail.error);
});

tool.addEventListener('progress', (event) => {
  if (event.detail.percent !== undefined) {
    console.log(`${event.detail.percent}%: ${event.detail.message ?? ''}`);
  } else {
    console.log(event.detail.message ?? 'Progress update');
  }
});
Streaming Output

Tools that return an AsyncIterable support two execution modes:

  • default (stream omitted/false): Armorer collects chunks into an array and returns that array as result.
  • stream: true: Armorer returns a live stream on ToolResult.stream (and ToolResult.result), and you consume it incrementally.
const streamTool = createTool({
  name: 'stream-tool',
  description: 'Emits tokens',
  input: z.object({}),
  async execute() {
    return {
      async *[Symbol.asyncIterator]() {
        yield 'hello';
        yield 'world';
      },
    };
  },
});

// Collect fallback (default)
const collected = await streamTool.execute({
  id: 'collect-1',
  name: 'stream-tool',
  arguments: {},
});
console.log(collected.result); // ['hello', 'world']

// Live stream mode
const live = await streamTool.execute(
  { id: 'live-1', name: 'stream-tool', arguments: {} },
  { stream: true },
);

for await (const chunk of live.stream!) {
  console.log('chunk', chunk);
}

Stream lifecycle events are emitted for both modes: stream-start, stream-chunk, stream-end, and stream-error. output-chunk continues to be emitted for compatibility.

Dispatching Progress Events

To report progress from inside a tool, use the dispatch function provided in the ToolContext (second argument to execute). Emit a progress event with an optional percent number (0–100) and an optional message:

const longTask = createTool({
  name: 'long-task',
  description: 'Does work in phases',
  input: z.object({ input: z.string() }),
  async execute({ input }, { dispatch }) {
    dispatch({ type: 'progress', detail: { percent: 10, message: 'Queued' } });
    // ... do work
    dispatch({ type: 'progress', detail: { percent: 50, message: 'Halfway' } });
    // ... do more work
    dispatch({ type: 'progress', detail: { percent: 100, message: 'Done' } });
    return input.toUpperCase();
  },
});

Then subscribe to progress on the tool:

longTask.addEventListener('progress', (event) => {
  console.log(`${event.detail.percent}%: ${event.detail.message ?? ''}`);
});

Search Tool for Agentic Workflows

Toolbox includes a pre-configured search tool that lets agents discover available tools dynamically. This is useful when you have many tools and want the large language model to find the right one for a task.

import { createToolbox, createTool } from 'armorer';
import { createSearchTool } from 'armorer/tools';
import { z } from 'zod';

const toolbox = createToolbox();

// Install the search tool - it auto-registers with the toolbox
createSearchTool(toolbox);

// Register your tools (can be done before or after the search tool)
createTool(
  {
    name: 'send-email',
    description: 'Send an email to recipients',
    input: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
    tags: ['communication'],
    async execute({ to, subject, body }) {
      return { sent: true };
    },
  },
  toolbox,
);

// Agents can now search for tools via toolbox.execute()
const result = await toolbox.execute({
  name: 'search-tools',
  arguments: { query: 'contact someone' },
});

console.log(result.result);
// [{ name: 'send-email', description: '...', tags: ['communication'], score: 1.5 }]

The search tool:

  • Auto-registers with the toolbox when created
  • Discovers tools dynamically - finds tools registered before or after it
  • Works with provider adapters - included in toOpenAITools(toolbox), toAnthropicTools(toolbox), and toGeminiTools(toolbox)
  • Supports semantic search when embeddings are configured on the toolbox

See Search Tool documentation for filtering by tags, configuration options, and agentic workflow examples.

Coding Toolbox (Read-Only)

armorer/coding is a first-party, read-only toolbox for letting an agent inspect a codebase: read-file, grep, and glob, all constrained to a single root directory by a path jail.

import { createToolbox } from 'armorer';
import { createCodingTools } from 'armorer/coding';

const { readFile, grep, glob, jail } = createCodingTools({ root: process.cwd() });
const toolbox = createToolbox([readFile, grep, glob]);

// Or skip the intermediate object and get the three tools as an array:
// const toolbox = createToolbox(createCodingToolbox({ root: process.cwd() }));
  • root is mandatory. Every path the three tools touch is resolved through a jail anchored to root. Absolute paths, .. traversal, and symlinks (at any path segment, including the leaf) that dereference outside root are rejected with a PathTraversalError.
  • read-file supports offset/limit line windows and caps the underlying read at maxBytes (default 256 KiB), reporting truncated/truncatedReason rather than silently dropping data.
  • grep runs an in-process regular expression (never child_process or a system grep) against files enumerated by Bun.Glob, optionally narrowed with a glob filter, and caps matches at maxMatches (default 200).
  • glob accepts repository-relative glob patterns only and caps results at maxResults (default 500).
  • All three report an explicit truncated: boolean marker whenever a cap was hit.
  • All three carry metadata: { readOnly: true, mutates: false, dangerous: false } and a readonly tag.

Non-goal: this toolbox is deliberately read-only. It does not include write, edit, or shell/bash tools — those are gated on the sandboxing decision tracked as AB-42, and will land as a separate toolbox once that decision is made.

TypeScript

Overview

TypeScript inference guidance and type-level patterns. For a complete list of exported types, see the API Reference.

Toolbox is written in TypeScript and provides full type inference:

const tool = createTool({
  name: 'typed-tool',
  description: 'A typed tool',
  input: z.object({
    count: z.number(),
    name: z.string().optional(),
  }),
  async execute(params) {
    // params is typed as { count: number; name?: string }
    return params.count * 2;
  },
});

// Return type is inferred
const result = await tool({ count: 5 }); // number

Documentation

Longer-form docs live in documentation/:

Roadmap

See the workspace roadmap for planned features, community requests, and version goals.

License

MIT. See LICENSE.

Keywords