spb/worthdoing Public
Autonomous investigation agent that discovers, challenges, and ranks things genuinely worth doing — Claude + Firecrawl, Next.js 16, PostgreSQL
TypeScript 91.5%
SQL 5.8%
CSS 2.2%
1/**2 * WorthDoing.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: src/lib/agent/tools.ts6 * Description: Agent tool contract — Zod schemas for validation and Anthropic tool definitions.7 */8import { z } from "zod";9import type Anthropic from "@anthropic-ai/sdk";1011export const TOOL_SCHEMA_VERSION = "1.0.0";1213/* ------------------------------ Zod schemas ------------------------------ */1415export const searchWebSchema = z.object({16 query: z.string().min(2).max(400),17 intent: z.enum(["explore", "verify", "falsify", "market", "technical", "competition"]),18 limit: z.number().int().min(3).max(10).optional(),19});2021export const scrapePageSchema = z.object({22 url: z.string().url(),23 reason: z.string().min(3).max(300),24});2526export const crawlSiteSchema = z.object({27 url: z.string().url(),28 limit: z.number().int().min(2).max(10),29 reason: z.string().min(3).max(300),30});3132export const extractStructuredSchema = z.object({33 urls: z.array(z.string().url()).min(1).max(5),34 prompt: z.string().min(5).max(1000),35 schema: z.record(z.string(), z.unknown()),36});3738export const createHypothesisSchema = z.object({39 title: z.string().min(3).max(160),40 statement: z.string().min(10).max(1000),41 rationale: z.string().min(5).max(1000),42 confidence: z.number().min(0).max(1),43 parent_hypothesis_id: z.string().uuid().optional(),44});4546export const updateHypothesisSchema = z.object({47 hypothesis_id: z.string().uuid(),48 status: z.enum(["investigating", "supported", "weakened", "validated"]).optional(),49 confidence: z.number().min(0).max(1),50 rationale: z.string().min(5).max(1000),51 adversarial_checked: z.boolean().optional(),52});5354export const rejectHypothesisSchema = z.object({55 hypothesis_id: z.string().uuid(),56 reason: z.string().min(5).max(1000),57});5859export const saveEvidenceSchema = z.object({60 source_id: z.string().uuid(),61 kind: z.enum(["support", "contradict", "context"]),62 quote: z.string().min(10).max(2000),63 summary: z.string().min(5).max(500),64 strength: z.number().min(0).max(1),65 links: z66 .array(67 z.object({68 hypothesis_id: z.string().uuid(),69 relation: z.enum(["supports", "contradicts"]),70 weight: z.number().min(0).max(1),71 }),72 )73 .max(5)74 .optional(),75});7677const scoreDimension = z.enum([78 "demand",79 "neglectedness",80 "feasibility",81 "why_now",82 "impact",83 "competition",84 "risk",85]);8687export const createOpportunitySchema = z.object({88 hypothesis_id: z.string().uuid().optional(),89 title: z.string().min(3).max(200),90 summary: z.string().min(20).max(1500),91 problem: z.string().min(20).max(3000),92 why_now: z.string().min(20).max(3000),93 risks: z.string().min(20).max(3000),94 skeptic_case: z.string().min(40).max(3000),95 evidence: z96 .array(97 z.object({98 evidence_id: z.string().uuid(),99 role: z.enum(["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"]),100 }),101 )102 .min(2)103 .max(30),104 competitors: z105 .array(106 z.object({107 name: z.string().min(1).max(120),108 url: z.string().url().optional(),109 description: z.string().max(400).optional(),110 note: z.string().max(400).optional(),111 }),112 )113 .max(15)114 .optional(),115 scores: z116 .array(117 z.object({118 dimension: scoreDimension,119 score: z.number().min(0).max(100),120 confidence: z.number().min(0).max(1),121 reasoning: z.string().min(10).max(1200),122 evidence_ids: z.array(z.string().uuid()).min(1).max(15),123 }),124 )125 .min(5)126 .max(7),127});128129export const synthesizeSchema = z.object({130 opportunity_id: z.string().uuid(),131});132133export const finishInvestigationSchema = z.object({134 conclusion: z.string().min(20).max(4000),135 outcome: z.enum(["opportunities_found", "insufficient_evidence", "no_strong_opportunity"]),136});137138export const toolSchemas = {139 search_web: searchWebSchema,140 scrape_page: scrapePageSchema,141 crawl_site: crawlSiteSchema,142 extract_structured: extractStructuredSchema,143 create_hypothesis: createHypothesisSchema,144 update_hypothesis: updateHypothesisSchema,145 reject_hypothesis: rejectHypothesisSchema,146 save_evidence: saveEvidenceSchema,147 create_opportunity: createOpportunitySchema,148 synthesize: synthesizeSchema,149 finish_investigation: finishInvestigationSchema,150} as const;151152export type ToolName = keyof typeof toolSchemas;153154/* --------------------------- Anthropic tool defs -------------------------- */155156const obj = (157 properties: Record<string, unknown>,158 required: string[],159): { type: "object"; properties: Record<string, unknown>; required: string[]; additionalProperties: false } => ({160 type: "object",161 properties,162 required,163 additionalProperties: false,164});165166export const anthropicTools: Anthropic.Tool[] = [167 {168 name: "search_web",169 description:170 "Search the web via Firecrawl. Use varied, precise queries. intent classifies the goal: explore (open discovery), verify (confirm a hypothesis), falsify (find counterevidence — mandatory for every high-confidence hypothesis), market (demand/willingness-to-pay signals), technical (feasibility), competition (existing solutions). Returns result URLs with titles and descriptions — it does NOT scrape pages.",171 input_schema: obj(172 {173 query: { type: "string", description: "The search query" },174 intent: {175 type: "string",176 enum: ["explore", "verify", "falsify", "market", "technical", "competition"],177 },178 limit: { type: "integer", minimum: 3, maximum: 10, description: "Result count (default 8)" },179 },180 ["query", "intent"],181 ),182 },183 {184 name: "scrape_page",185 description:186 "Fetch one page's main content as markdown (cached 24h; identical URLs are deduplicated automatically). Returns a source_id you must use when saving evidence from this page, plus a content excerpt. Scrape only pages likely to contain decision-relevant signal.",187 input_schema: obj(188 {189 url: { type: "string", description: "Absolute URL to scrape" },190 reason: { type: "string", description: "One line: what signal you expect this page to contain" },191 },192 ["url", "reason"],193 ),194 },195 {196 name: "crawl_site",197 description:198 "Crawl a small set of pages from one site (max 10 pages). Expensive and slow — use only when one site clearly holds multiple relevant pages (e.g. a changelog + pricing + docs). Never crawl entire domains.",199 input_schema: obj(200 {201 url: { type: "string" },202 limit: { type: "integer", minimum: 2, maximum: 10 },203 reason: { type: "string" },204 },205 ["url", "limit", "reason"],206 ),207 },208 {209 name: "extract_structured",210 description:211 "Extract structured data from up to 5 URLs against a JSON schema (Firecrawl extract). Use for comparable facts across pages (pricing, feature lists, launch dates). Slower than scraping — prefer scrape_page unless you need cross-page structure.",212 input_schema: obj(213 {214 urls: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },215 prompt: { type: "string", description: "What to extract" },216 schema: { type: "object", description: "JSON schema of the desired output" },217 },218 ["urls", "prompt", "schema"],219 ),220 },221 {222 name: "create_hypothesis",223 description:224 "Create a falsifiable hypothesis about something potentially worth doing. Statement must be specific enough that evidence could contradict it. Start confidence honestly (usually 0.3–0.6). Use parent_hypothesis_id when branching a variant of an existing hypothesis.",225 input_schema: obj(226 {227 title: { type: "string" },228 statement: { type: "string", description: "Falsifiable statement" },229 rationale: { type: "string" },230 confidence: { type: "number", minimum: 0, maximum: 1 },231 parent_hypothesis_id: { type: "string" },232 },233 ["title", "statement", "rationale", "confidence"],234 ),235 },236 {237 name: "update_hypothesis",238 description:239 "Update a hypothesis's confidence and status as evidence accumulates. Lowering confidence when counterevidence appears is progress, not failure. Set adversarial_checked=true only after you have genuinely searched for counterevidence (intent=falsify) for this hypothesis.",240 input_schema: obj(241 {242 hypothesis_id: { type: "string" },243 status: { type: "string", enum: ["investigating", "supported", "weakened", "validated"] },244 confidence: { type: "number", minimum: 0, maximum: 1 },245 rationale: { type: "string", description: "What changed and why" },246 adversarial_checked: { type: "boolean" },247 },248 ["hypothesis_id", "confidence", "rationale"],249 ),250 },251 {252 name: "reject_hypothesis",253 description:254 "Reject a hypothesis the evidence does not support. A rejected hypothesis is valuable output — reject decisively rather than letting weak hypotheses linger.",255 input_schema: obj(256 {257 hypothesis_id: { type: "string" },258 reason: { type: "string" },259 },260 ["hypothesis_id", "reason"],261 ),262 },263 {264 name: "save_evidence",265 description:266 "Save a piece of evidence from a scraped source. quote must be an excerpt from the actual page content (near-verbatim). Link it to hypotheses it supports or contradicts. Duplicate evidence (same source + same quote) is rejected automatically.",267 input_schema: obj(268 {269 source_id: { type: "string", description: "source_id returned by scrape_page/crawl_site" },270 kind: { type: "string", enum: ["support", "contradict", "context"] },271 quote: { type: "string", description: "Near-verbatim excerpt from the source" },272 summary: { type: "string", description: "One-line interpretation" },273 strength: { type: "number", minimum: 0, maximum: 1 },274 links: {275 type: "array",276 maxItems: 5,277 items: {278 type: "object",279 properties: {280 hypothesis_id: { type: "string" },281 relation: { type: "string", enum: ["supports", "contradicts"] },282 weight: { type: "number", minimum: 0, maximum: 1 },283 },284 required: ["hypothesis_id", "relation", "weight"],285 additionalProperties: false,286 },287 },288 },289 ["source_id", "kind", "quote", "summary", "strength"],290 ),291 },292 {293 name: "create_opportunity",294 description:295 "Create an opportunity from a hypothesis that survived the skeptic phase. Every field must be defensible from saved evidence. skeptic_case is the strongest honest argument AGAINST pursuing this. scores must cover at least: demand, neglectedness, feasibility, why_now, impact (competition and risk encouraged). Each score cites the evidence_ids that justify it — never invent scores without evidence.",296 input_schema: obj(297 {298 hypothesis_id: { type: "string" },299 title: { type: "string" },300 summary: { type: "string" },301 problem: { type: "string" },302 why_now: { type: "string" },303 risks: { type: "string" },304 skeptic_case: { type: "string" },305 evidence: {306 type: "array",307 minItems: 2,308 items: {309 type: "object",310 properties: {311 evidence_id: { type: "string" },312 role: {313 type: "string",314 enum: ["demand", "neglect", "feasibility", "why_now", "risk", "competition", "context"],315 },316 },317 required: ["evidence_id", "role"],318 additionalProperties: false,319 },320 },321 competitors: {322 type: "array",323 items: {324 type: "object",325 properties: {326 name: { type: "string" },327 url: { type: "string" },328 description: { type: "string" },329 note: { type: "string" },330 },331 required: ["name"],332 additionalProperties: false,333 },334 },335 scores: {336 type: "array",337 minItems: 5,338 items: {339 type: "object",340 properties: {341 dimension: {342 type: "string",343 enum: ["demand", "neglectedness", "feasibility", "why_now", "impact", "competition", "risk"],344 },345 score: { type: "number", minimum: 0, maximum: 100 },346 confidence: { type: "number", minimum: 0, maximum: 1 },347 reasoning: { type: "string" },348 evidence_ids: { type: "array", items: { type: "string" }, minItems: 1 },349 },350 required: ["dimension", "score", "confidence", "reasoning", "evidence_ids"],351 additionalProperties: false,352 },353 },354 },355 ["title", "summary", "problem", "why_now", "risks", "skeptic_case", "evidence", "scores"],356 ),357 },358 {359 name: "synthesize",360 description:361 "Generate the full streamed report for an opportunity you already created. The backend writes the report from the opportunity's saved state and evidence — call this once per opportunity, after its evidence and scores are complete.",362 input_schema: obj({ opportunity_id: { type: "string" } }, ["opportunity_id"]),363 },364 {365 name: "finish_investigation",366 description:367 "End the investigation with a conclusion. Blocked while high-confidence hypotheses remain adversarially unchecked. 'The evidence does not justify a strong opportunity' is a valid, valuable outcome — use outcome=no_strong_opportunity or insufficient_evidence honestly.",368 input_schema: obj(369 {370 conclusion: { type: "string" },371 outcome: {372 type: "string",373 enum: ["opportunities_found", "insufficient_evidence", "no_strong_opportunity"],374 },375 },376 ["conclusion", "outcome"],377 ),378 },379];380