Last updated by .

Build an OpenCandle Investigation Tool

Add a new data tool to OpenCandle by submitting a PR. A good OpenCandle tool behaves like an investigator's instrument: it fetches evidence, preserves source/freshness context, formats the result clearly, and leaves synthesis to the model.

For a working reference, see src/tools/sentiment/reddit-sentiment.ts.

Quick Start

  1. Create your tool file: src/tools/<domain>/my-tool.ts
  2. Add a provider if needed: src/providers/my-source.ts
  3. Add a type if needed: src/types/my-domain.ts
  4. Register in src/tools/index.tsgetAllTools()
  5. Add fixture JSON in tests/fixtures/<provider>/
  6. Write tests, run npm test, submit PR

The Tool Contract

Every tool is an AgentTool with Typebox parameters:

import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";

const params = Type.Object({
  symbol: Type.String({ description: "Stock ticker symbol (e.g. AAPL)" }),
  days: Type.Optional(Type.Number({ description: "Lookback in days. Default: 7" })),
});

export const twitterSentimentTool: AgentTool<typeof params, TwitterSentiment> = {
  name: "get_twitter_sentiment",
  label: "Twitter Sentiment",
  description: "Analyze Twitter/X sentiment for a stock ticker",
  parameters: params,
  async execute(toolCallId, args) {
    // Fetch data via provider, format results
    return {
      content: [{ type: "text", text: "Formatted human-readable output" }],
      details: { sentiment: 0.72, volume: 1234 },
    };
  },
};

Naming Rules

  • snake_case with a verb prefix: get_, analyze_, search_, calculate_, compare_, compute_, track_, manage_, backtest_, list_, fetch_, check_
  • You can use createTool() from src/tool-kit.ts to validate this at creation time (optional convenience)

Parameters

  • Use Typebox Type.Object({...}) as the root (recommended convention)
  • Every parameter needs a description — the agent reads these to decide how to call the tool
  • Use Type.Optional() for non-required params

Return Format

{
  content: [{ type: "text", text: string }],  // Displayed to user
  details: T,                                   // Structured data for agent
}
  • content — human-readable. Format nicely (tables, bullet points)
  • details — typed structured data the agent reasons over

Where Files Go

src/tools/<domain>/my-tool.ts       # Tool implementation
src/providers/my-source.ts          # API client (if new data source)
src/types/my-domain.ts              # Types (if new domain)
src/tools/index.ts                  # Register in getAllTools()
tests/fixtures/<provider>/          # Fixture JSON for mock responses
tests/unit/tools/my-tool.test.ts    # Unit tests

Registering the Tool

Add your export to src/tools/index.ts:

import { twitterSentimentTool } from "./sentiment/twitter-sentiment.js";

export function getAllTools(): AgentTool<any>[] {
  return [
    // ... existing tools
    twitterSentimentTool,
  ];
}

That's it — the tool is now available to the agent.

If the tool should be available in normal OpenCandle conversations, also wire it into active-tool selection:

  • Add the tool name to the relevant bundle in src/routing/route-manifest.ts (for example, core market tools belong in TOOL_BUNDLE_TOOLS.core_market).
  • Update the tool catalog text in src/prompts/context-builder.ts and, when the global prompt mentions the same domain, src/system-prompt.ts.
  • Add tests showing the tool is present for the intended bundle and absent from unrelated bundles.
  • Add prompt or harness coverage proving the agent chooses the new tool for the intended prompt class and keeps existing tools for adjacent tasks.

screen_stocks is the reference for a provider-backed tool that needed this wiring: it is exposed for breadth/screening prompts, while Yahoo-backed quote/history tools remain the right choice for single-security quote or history prompts.

Using OpenCandle Infrastructure

HTTP Client

import { httpGet, httpPost } from "../../infra/http-client.js";

const data = await httpGet<MyApiResponse>("https://api.example.com/data", {
  headers: { Authorization: `Bearer ${apiKey}` },
});

const posted = await httpPost<MyApiResponse>("https://api.example.com/search", {
  query: "AAPL",
});

httpPost is internal first-party infrastructure at the moment; add-on packages should continue using the currently exported opencandle/tool-kit APIs unless that package subpath explicitly exports new HTTP helpers.

Caching

import { cache, TTL } from "../../infra/cache.js";

const cached = cache.get<MyData>("my-tool:AAPL");
if (cached) return cached;

const fresh = await fetchData("AAPL");
cache.set("my-tool:AAPL", fresh, TTL.MINUTES_15);

For providers that may fail intermittently, use cache.getStale() to return the last known value within a longer window:

import { STALE_LIMIT } from "../../infra/cache.js";

const stale = cache.getStale<MyData>("my-tool:AAPL", STALE_LIMIT.SENTIMENT);
if (stale) return stale.value; // serve stale data while provider is down

Rate Limiting

import { rateLimiter } from "../../infra/rate-limiter.js";

await rateLimiter.acquire("my-api");

Configure first-party provider buckets in src/infra/rate-limiter.ts, and use the provider ID consistently in tests and wrapProvider().

New Provider Checklist

For a first-party provider, follow the Yahoo provider pattern in src/providers/yahoo-finance.ts:

  • Put the API client in src/providers/<provider>.ts with verb-prefixed async functions that return typed objects.
  • Use shared httpGet/httpPost, rateLimiter.acquire("<provider>"), cache, TTLs, and stale-cache fallback instead of provider-local fetch/retry logic.
  • Export the provider functions from src/providers/index.ts.
  • Save deterministic fixtures in tests/fixtures/<provider>/; unit tests must mock globalThis.fetch and must not call live APIs.
  • Decode provider responses defensively and surface provider limits or freshness caveats in tool output.
  • If the provider backs a new tool, register the tool in src/tools/index.ts, route bundles, prompt catalog text, and focused tests.

Provider Wrapping

Use wrapProvider() for circuit-breaking and error handling:

import { wrapProvider } from "../../providers/wrap-provider.js";

const result = await wrapProvider("my-source", () => fetchFromMyApi(symbol));
if (result.status === "unavailable") {
  return { content: [{ type: "text", text: `Data unavailable: ${result.reason}` }], details: null };
}
// result.stale is true when serving cached data after a provider failure

For multi-provider fallback, use withFallback() — see src/tools/market/stock-quote.ts.

For a real-world example that uses wrapProvider with stale fallback and login-specific error detection, see src/tools/sentiment/twitter-sentiment.ts.

Testing

Mock globalThis.fetch with fixture JSON. No live API calls in unit tests.

import { describe, it, expect, vi, beforeEach } from "vitest";

const FIXTURE = { sentiment: 0.72, posts: 150 };

describe("get_twitter_sentiment", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(FIXTURE),
    }));
  });

  it("returns sentiment data for a valid ticker", async () => {
    const result = await twitterSentimentTool.execute("call-1", { symbol: "AAPL" });
    expect(result.details.sentiment).toBe(0.72);
    expect(result.content[0].text).toContain("AAPL");
  });
});

Save fixture JSON in tests/fixtures/<provider>/ so tests are deterministic.

Add-on Packages (Advanced)

If your tool has heavy dependencies or needs separate maintenance, you can ship it as a standalone npm package instead. It's a Pi extension that imports from opencandle/tool-kit:

// extension.ts in your separate package
import type { ExtensionAPI } from "opencandle/tool-kit";
import { registerTools } from "opencandle/tool-kit";
import { myTool } from "./tools/my-tool.js";

export default function(pi: ExtensionAPI): void {
  registerTools(pi, [myTool]);
}
// package.json
{
  "pi": { "extensions": ["./dist/extension.js"] },
  "keywords": ["opencandle-tools"],
  "peerDependencies": { "opencandle": "*" }
}

Pi discovers it automatically when installed. For Pi extension lifecycle details, see Pi documentation.

Checklist

  • [ ] Tool name is snake_case with verb prefix
  • [ ] Every parameter has a description
  • [ ] execute() returns { content, details }
  • [ ] Uses cache and rateLimiter for external API calls
  • [ ] Tests mock globalThis.fetch with fixtures
  • [ ] Tool registered in src/tools/index.ts

Investigation Quality Checklist

  • [ ] The tool reports source/provider identity in structured details when useful
  • [ ] Missing credentials produce a clear setup path instead of a vague failure
  • [ ] Stale or partial data is labeled in the user-facing content
  • [ ] The tool avoids advice language such as "buy", "sell", or "safe"
  • [ ] Downside or data-quality caveats are preserved for the analyst prompt
  • [ ] Fixture data is realistic enough to catch formatting and parsing regressions