0028 — Tool Catalog consolidation: one home in src/lib/tools/

Context

PropFlow ships two tool surfaces:

For ~6 months the two catalogs were independent. Adding a new tool that worked on both voice and ops surfaces meant:

This dual-source state produced one production incident (2026-05-10: close_work_order had an ops handler at agents/clara/lib/agent/tools/index.ts but no voice-catalog entry, so ElevenLabs never registered it, so Clara could not call it on calls — silently). A drift guard was added (voice-tools-elevenlabs-drift.test.ts enforcing set equality with the live agent), but the underlying problem was structural: two physical catalogs with no compiler-enforced relationship.

The architectural pressure landed in May 2026 when Tools Platform discipline tightened — every dispatchable action moved into src/lib/tools/ with the catalog as the single source of truth for what the system can do.

Decision

All tools live in one home: src/lib/tools/. The voice catalog becomes a derived projection of the ops catalog, not a parallel file.

Concretely:

  1. ToolSpec (defined at src/lib/tools/types.ts) gains an optional voice?: VoiceMetadata field carrying everything voice-specific:
    • channels: ('voice' | 'sms' | …)[] — which channels expose this tool
    • agents: string[] — which ElevenLabs agent IDs register it (production / staging variants)
    • routeSlug?: string — overrides the auto-derived /api/voice/tools/<slug> path
    • description?: string — voice-specific description override (different from the ops description)
    • inputSchema?: object — voice-specific input schema (typically narrower than the ops schema)
    • deprecated?: { since: string; replacedBy?: string } — for voice-alias entries kept temporarily during a tool rename
  2. agents/clara/lib/agent/tool-catalog.ts becomes a thin re-export of src/lib/tools/voice-projection.ts. The projection filters toolCatalog to entries with a voice block and reshapes each one into the ElevenLabs registration shape (description override applied, inputSchema override applied, route resolved).
  3. agents/clara/lib/agent/tools/index.ts (the runtime handler dispatcher) continues to map voice-side tool calls to handler functions, but the registered set it serves is now derived, not hand-maintained.

The contract between the ops spec and the projected voice entry is pinned by src/__tests__/voice-projection-coverage.test.ts — every ops spec with a voice block must project cleanly to the expected ElevenLabs shape, and the projection must produce exactly the expected entry set. The existing drift guard at src/__tests__/voice-tools-elevenlabs-drift.test.ts continues to pin the projection against the live ElevenLabs agent's tool list, so both sides of the chain (spec → projection → ElevenLabs) are enforced.

Consequences

Becomes easier:

Becomes harder:

Follow-up work this commits us to:

Alternatives considered

A. Keep the voice catalog separate, add stricter drift guards. Rejected. The 2026-05-10 incident showed that drift guards catch the symptom (Clara can't call X) but the underlying maintenance burden — keeping two definitions in lockstep on every PR — still rots over time. Each guard is a tax on every contributor; consolidation removes the tax instead.

B. Move ops tools into the voice catalog (the reverse direction). Rejected. The ops catalog is the canonical "what can the system do" surface — it powers /admin/dev/tools, Pipeline Lab, evals, the bundled Tools eval at evals/run-tools-eval.ts. Voice is one channel that exposes a subset; voice should be a projection of the canonical surface, not the source.

C. Generate the voice catalog from the ops catalog at build time (codegen). Rejected. Runtime projection is simpler — it lets the dev server, Atlas page, and tests all read the live projection without a generate step, and changes propagate immediately without a build-cache invalidation. The cost (one extra map/filter pass per import) is trivial.

D. Treat the voice catalog as a "view" defined in ElevenLabs's dashboard, not in source. Rejected. ElevenLabs's dashboard is reset by every post-merge sync (per the auto-sync architecture documented in CLAUDE.md "ElevenLabs Voice Agent" section). Dashboard edits are ephemeral; source is canonical. The voice catalog must live in source; the only question is which file. ADR-0028 says: one file, derived.


Status of the migration

Closed by PR #1012 (2026-05-12). All 38 voice-registered tools are now derived from src/lib/tools/. The drift-guard ratchet (voice-projection-coverage + voice-tools-elevenlabs-drift) prevents reintroduction of a parallel voice file.