0028 — Tool Catalog consolidation: one home in src/lib/tools/
- Status: Accepted
- Date: 2026-05-12
- Deciders: Gera (Jose) — direct endorsement; landed via PR #1012
- Implementing PR: #1012
Context
PropFlow ships two tool surfaces:
- Ops catalog —
src/lib/tools/{appfolio,leasing,maintenance,renewals,resident-services,platform}.ts. Powers the/admin/dev/toolsrunner, the Pipeline Lab, and every L4/handler dispatch that originates from a UI or Lambda call site. - Voice catalog — historically
agents/clara/lib/agent/tool-catalog.ts, a hand-maintained 38-entry file used to register tools with ElevenLabs Conversational AI so Clara could invoke them on voice calls.
For ~6 months the two catalogs were independent. Adding a new tool that worked on both voice and ops surfaces meant:
- Writing the ops
ToolSpec(input/output schema, runner, dispatch wiring). - Hand-copying a narrower variant into the voice catalog (different description for voice ergonomics, different
inputSchemato fit ElevenLabs's stricter param shape, arouteSlugfor the/api/voice/tools/[tool]handler). - Maintaining the two definitions in lockstep — when a parameter renamed or a description changed, both files had to move together or Clara drifted from production.
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:
ToolSpec(defined atsrc/lib/tools/types.ts) gains an optionalvoice?: VoiceMetadatafield carrying everything voice-specific:channels: ('voice' | 'sms' | …)[]— which channels expose this toolagents: string[]— which ElevenLabs agent IDs register it (production / staging variants)routeSlug?: string— overrides the auto-derived/api/voice/tools/<slug>pathdescription?: 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
agents/clara/lib/agent/tool-catalog.tsbecomes a thin re-export ofsrc/lib/tools/voice-projection.ts. The projection filterstoolCatalogto entries with avoiceblock and reshapes each one into the ElevenLabs registration shape (description override applied, inputSchema override applied, route resolved).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:
- Adding a new tool that works on voice AND ops is one file edit instead of two — add the
voice: {…}block to the existing ops spec and the projection picks it up. - Renaming a parameter or description automatically propagates to the voice surface.
- The Atlas page (
/admin/dev/atlas) and developer surfaces can derive voice badges directly fromspec.voiceinstead of cross-referencing two catalogs. scripts/reconcile-tool-catalog.tsbecomes a reporter (it used to be a writer that could push divergent voice-catalog changes into source).
Becomes harder:
- Voice-only tools (16 in the migration set — leasing/maintenance/renewals/platform) must now live in the ops catalog as
ToolSpecentries even though they only run on voice. This is the right shape — every dispatchable action belongs in the central catalog — but it expanded the ops catalog by 16 entries that previously lived only in the voice file. - Deprecation handling is more nuanced. When ElevenLabs needs a temporary alias entry (e.g., agent prompts still call
old_namewhile we migrate tonew_name), the projection emits adeprecatedblock on the alias entry that the drift guard tolerates. Two test exceptions document this: seetool-catalog-state.test.tsandtools-catalog-coverage-clara.test.ts. The deprecation pattern is supported, but every alias adds a row to the "intentionally divergent" allowlist that has to be cleaned up when the alias retires.
Follow-up work this commits us to:
- Every new tool goes through
src/lib/tools/— the runbook atdocs/runbooks/adding-a-new-tool.mdwalks the four-file change, and the drift guards fail loud at CI time if any step is missed. - Deprecation aliases get explicit
deprecated.sincedates so we can track and retire them on a known cadence rather than letting them ossify. - If a future surface (e.g., a Slack-bot tool channel, an MCP server adapter) needs its own projection of the catalog, the pattern is: add a
slack?: SlackMetadatafield toToolSpec, write a projection atsrc/lib/tools/slack-projection.ts, pin it with a coverage test. Same shape as the voice projection.
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.