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
- Create your tool file:
src/tools/<domain>/my-tool.ts - Add a provider if needed:
src/providers/my-source.ts - Add a type if needed:
src/types/my-domain.ts - Register in
src/tools/index.ts→getAllTools() - Add fixture JSON in
tests/fixtures/<provider>/ - 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()fromsrc/tool-kit.tsto 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 inTOOL_BUNDLE_TOOLS.core_market). - Update the tool catalog text in
src/prompts/context-builder.tsand, 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>.tswith 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 mockglobalThis.fetchand 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
cacheandrateLimiterfor external API calls - [ ] Tests mock
globalThis.fetchwith 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