Skip to content

Tool Development (Advanced)

This guide covers patterns and edge cases when authoring tools beyond the basic examples.

Goals of a Good Tool

  • Clear, concise purpose (single responsibility)
  • Deterministic output structure
  • Fast failure on invalid input
  • Bounded payload size (avoid giant raw strings when possible)
  • Helpful error surfaces (actionable error messages)

Anatomy Recap

ts
const weather = createTool({
  name: "weather_lookup",
  description: "Lookup current weather for a city",
  schema: z.object({ city: z.string().min(2), units: z.enum(["c","f"]).default("c") }),
  func: async ({ city, units }) => { /* ... */ return { city, units, temp: 21 }; }
});

Already have an existing LangChain or MCP tool? Wrap it via fromLangchainTools([...]) and mix it with your custom tools:

ts
import { fromLangchainTools } from "@cognipeer/agent-sdk";

const lcTools = await client.getTools();
const tools = [...fromLangchainTools(lcTools), weather];

fromLangchainTools keeps the input objects lazy—if @langchain/core is available the wrapper regenerates LangChain-compatible tools; otherwise it falls back to invoking them through the lightweight SDK contract.

Input Validation

Rely on Zod for strong guarantees. Prefer transforming vague primitives into structured fields rather than post‑parsing inside func.

Anti-PatternImprovement
z.string() for numeric fieldz.number().int().min(0)
Large union of literals but semantically sameUse enum + mapping
Opaque free-form JSONExplicit object schema

Error Handling Strategy

Throw errors with a concise message – Smart Agent will surface them in the tool message. Avoid attaching huge stack traces as content.

ts
if (!apiKey) throw new Error("MISSING_API_KEY: Set WEATHER_API_KEY env var");

Consider user-recoverable errors vs fatal:

  • Recoverable (e.g. rate limit) – short message, model may retry with altered parameters.
  • Fatal (schema misuse) – explicit message discouraging retry.

Payload Size Control

Large tool results push token pressure higher. Techniques:

  1. Return structured arrays with trimmed fields (title, url, score) rather than full documents.
  2. Keep verbose/raw content behind an optional flag (e.g. includeRaw: boolean).
  3. For binary / huge text: store externally, return reference ID.

Idempotency & Caching

If your tool is deterministic for a given input, opt into the built-in per-invoke result cache:

ts
const lookup = createTool({
  name: "lookup_owner",
  description: "Return the owner for a project code",
  schema: z.object({ code: z.string() }),
  func: async ({ code }) => projectRegistry[code],
  cache: true, // or { keyFn: (a) => a.code, ttlMs: 60_000 }
});

The runtime hashes the validated args, stores results on state.toolCache, and short-circuits duplicate calls inside the same invoke. Use keyFn to canonicalize keys (drop nonces) and ttlMs to expire entries. Per-tool execution limits (maxExecutionsPerRun) and the cache work together: a cached hit does NOT consume an execution count toward your global maxToolCalls budget? It still increments toolCallCount, but it bypasses func and any external rate limit. If a non-deterministic tool is incorrectly flagged cache: true, the model never sees fresh results — only enable it on genuinely idempotent operations.

Retry, Backoff, and Circuit Breaker

For tools that talk to flaky external APIs, declare a retry policy on the tool itself rather than rolling your own loop inside func:

ts
const search = createTool({
  name: "search",
  description: "Search the docs index",
  schema: z.object({ q: z.string() }),
  func: async ({ q }) => client.search(q),
  retry: {
    maxRetries: 3,
    backoffMs: 250,                              // doubled each attempt
    shouldRetry: (err) => !`${err?.message}`.includes("UNAUTHORIZED"),
    circuitBreakerThreshold: 5,                  // open after 5 consecutive failures
  },
});
  • The first attempt + up to maxRetries retries with exponential backoff happen transparently — only the final result (success or failure) reaches the agent state.
  • shouldRetry lets you skip retries for non-transient errors (auth, 4xx, business logic).
  • The circuit breaker counts consecutive failures across the invoke. Once it trips, the tool short-circuits with a breaker message until the loop ends.

Provider-level retries (429 / 5xx with Retry-After) are handled separately by the native provider layer and do not consume maxRetries.

Parallel Calls

maxParallelTools controls concurrency per agent turn. The runtime fans non-approval tool calls out across a bounded worker pool while preserving tool-result ordering for strict-pairing providers (Bedrock / Anthropic). Approval-required tools always run sequentially. If your tool is resource-heavy, document the expected latency so callers can tune the limit.

Side Effects

Avoid unbounded side effects (writing files, mutating global state). Keep tools primarily query or pure transformation style.

Security Guidelines

  • Never echo secrets back.
  • Sanitize user-provided URLs / paths.
  • Enforce timeouts in long-running fetches.

Testing Tools

Create a thin unit test harness:

ts
const input = { city: "Berlin", units: "c" };
await expect(weather.func(input)).resolves.toMatchObject({ city: "Berlin" });

For fake/offline mode, stub network calls with deterministic fixtures.

Observability

Emit relevant metadata within the returned object if it aids observability (e.g. source: 'cache'). Keep it concise.

Human-in-the-loop approvals

Set needsApproval: true on any tool that should pause execution until a reviewer signs off. Optional helpers:

  • approvalPrompt: short text for UI surfaces explaining what needs to be reviewed.
  • approvalDefaults: arbitrary JSON you can use to pre-populate review forms.

When the model selects the tool, the SDK stores a pending entry in state.pendingApprovals. Your host app can present the request to a human and then resolve it. See the dedicated Tool Approvals guide for end-to-end wiring, including resolveToolApproval usage, event payloads, and checkpoint integration.

Per-tool execution limits

Set maxExecutionsPerRun when a tool should only succeed a fixed number of times within a single run:

ts
const weather = createTool({
  name: "weather_lookup",
  description: "Lookup current weather for a city",
  schema: z.object({ city: z.string().min(2) }),
  func: async ({ city }) => ({ city, temp: 21 }),
  maxExecutionsPerRun: 3,
});
  • maxExecutionsPerRun: 3 means the fourth successful call is skipped with a tool message.
  • maxExecutionsPerRun: null or omitting the field means unlimited usage.
  • This limit is per tool and per run; it does not replace global controls like limits.maxToolCalls or limits.maxParallelTools.

Patterns

PatternDescriptionWhen to Use
Delegation ToolWrap another agent (agent.asTool())Complex substeps
Handoff ToolTransfer runtime (agent.asHandoff())Domain shift / expertise
Retrieval ToolThin API client returning structured hitsSearch / knowledge
Aggregator ToolCalls multiple internal functions then mergesCompose primitives

Failure Modes & Mitigation

FailureMitigation
Slow upstream APIAdd timeout + partial fallback
Intermittent 500sBasic retry with backoff (wrap inside func)
Large responseSummarize, or slice top-N before returning
Ambiguous user intentAdd disambiguation fields in schema

Anti-Patterns

  • Returning full HTML pages or giant base64 blobs.
  • Encoding additional JSON as a string inside an already JSON-returning tool.
  • Overloading a tool to perform multiple unrelated tasks.

Checklist Before Adding a Tool

  • [ ] Name is action-oriented and unique
  • [ ] Description clear for LLM selection
  • [ ] Schema restrictive but ergonomic
  • [ ] Errors are informative
  • [ ] Output size bounded
  • [ ] No secret leakage

Agent SDK is part of the Cognipeer platform.