Appearance
Superseded — read the findings first
This document was written before the idea was built and tested. Its problem analysis still holds; its recommendation does not.
A working implementation was built and run head-to-head against the current approach on live calls. It produced no improvement in call time or accuracy. The prompt-size benefit is real but is a scaling property only, and the conversation-quality gains that motivated this proposal turned out to come from writing richer service content — which is achievable without the architecture.
See Just-in-time service context (findings) for what was measured, and for the direction the evidence points instead: mid-call goal evaluation rather than context management.
Kept for the problem statement and incident analysis below, which remain the clearest record of the original failure.
Every service's prompt logic is compiled statically into every call — make the Knowledge Base rich and inject per-service context just-in-time
One-line summary. A team's agent prompt is compiled once, statically, so ALL conditional logic for ALL services the business offers must be baked in "just in case" — which bloats the prompt and, worse, leaves service-specific rules sitting in-context on calls they have nothing to do with, where they bleed into decisions (two confirmed production incidents; 16 fleet locations carrying the same contaminating clause). The proposal: make each service's entry in the Knowledge Base (
system_prompt_configs.job_types— "Knowledge Base" is the platform's own name for it, perregistry.ts:172) rich enough to carry that service's full follow-up/conditional/combinability corpus, keep the rich content out of the standing prompt, and inject only the relevant services' context via a tool call the moment the caller has said what they need — lazily, just-in-time, instead of statically, always-present. The tool accepts all n stated services at once and its result also reports whether any of them may be grouped into one appointment. Layer choice decided 2026-08-04: a dedicated identification-time tool (Option B below), not an extension of the booking tool's result — waiting for booking is too late.
Problem statement
Avoca compiles one static system prompt per team (6-tier scope merge + LiquidJS render). At compile time nobody knows which service a given caller will ask about, so every branch of every service's handling logic has to be present in the standing prompt. Two costs follow:
- Bloat. The prompt grows with the full corpus of conditional/follow-up logic for every service the business offers. Any single call needs one or two of those branches; it carries all of them.
- Cross-contamination. Logic belonging to a service that is not relevant to the current call still sits in the model's context, available to bleed into decisions it should never touch.
Cost #2 is not hypothetical. Two production calls, traced end-to-end on 2026-08-03 (full evidence: workbench plan .indusk/planning/multiservice-booking-kb-exceptions/ — research.md and adr.md):
- Team 3082 (SameDay), call
41174f6a— caller stated a water heater repair (plumbing) and an AC control-panel wiring repair (HVAC/electrical). A single hardcoded exception in the locked, always-presentmodule_multiple_service_requests— "Heater and AC maintenance CAN be combined into one appointment" — collided lexically with "water heater." One appointment was booked; the AC issue survives only as free-text in the job notes. - Team 2744 (LaPlante), call
ac8e7583— caller asked for heat pump maintenance and maintenance on an unrelated air-cooled generator. "Generator" has zero lexical or functional overlap with "Heater and AC" — and the agent combined them anyway. The model treated the mere existence of one named exception as a general, unwritten license: "both are low-urgency maintenance, so combining is fine."
Fleet-wide, the same flawed clause (or a close variant) sits in 7 blueprint-scope defaults plus 9 independently-authored team overrides across 5 more teams — 16 locations. Teams that separately rewrote the module, sometimes making other parts far more rigorous, still kept this exact clause. That is a systemic authoring pattern, not a one-off mistake: when the only home for service-specific logic is a static shared prompt module, authors cram it there, and it contaminates every call the team takes.
An interim wording fix has shipped (workbench plan .indusk/planning/multiservice-exception-wording-fix/brief.md). It closes the specific lexical collision. It cannot close the class: call 2 proves the over-generalization happens independent of the specific words used. The class-level fix is architectural, and that is what this document proposes.
What "the Knowledge Base" holds today, and why it's too thin
Each service a team offers is one entry in system_prompt_configs.job_types (jsonb), validated by businessInformationJobTypeSchema (business-information.ts:206-215):
service_type, custom_service_type, appointment_type, custom_appointment_type,
description, keywords, examples, booking_overrideThat's a name, a routing hint, and a few classifier keywords. At compile time, resolveServicesProvidedForPrompt (business-information.ts:546-564) → generateServicesProvidedFromJobTypes (:454-518) flattens the array into the services_provided bullet list, pushed into the compiler-variable merge by extractSystemPromptConfigVariables (system-prompt-configs.ts:139, services_provided branch at :202-211, blueprint/compiler pipeline only — the legacy prompt builder path uses the stored column).
Everything richer than that — "for a water heater, ask gas or electric and tank age," "reheming and hemming are the same crew, one visit," "this service needs a two-hour window" — has no structured home, so it lands in hand-authored module text, statically, for every call. The existing per-service special-case mechanism proves the point: booking_override → getBookingOverridesFromJobTypes (business-information.ts:582-601) → buildBookingOverridesBlock (booking-overrides.ts:79-110) is per-service structured data, but it still renders into the always-present OVERRIDES — EVALUATE CONTINUOUSLY section of every compiled prompt via {{booking_overrides_block}} (compiler-variables.ts:55-63). Structured storage, static delivery — it solves the authoring problem the old way and keeps the contamination problem.
The identification moment already exists
The platform already has a reliable, deterministic mechanism that resolves "which of this team's configured services does the caller want": determineJobTypeFromTranscript (job-types.ts:35-121) — a GPT-5.2 structured-output call (getBookingModelId(teamId, 'gpt-5.2'), temperature 0) whose result is forced by a Zod enum built from the team's actual configured job-type ids (jobTypeRowId: z.enum([...]), :85-91). It runs today inside the getBookingInfo tool: getBookingInformationByStAtAddV2Tool → getBookingWindowsAvailabilities → bookingWindowsAvailability.ts:509 → getJobTypeFromTranscript (helpers.ts:1272-1338, classifier call at :1324-1331).
That moment — the service just got identified against the team's real configured list — is exactly when the relevant service's rich context should enter the conversation. Today, nothing enters: the classifier result is used only to query availability.
Current behavior — everything pre-loaded, statically
Source citations:
business-information.ts:206-215— the thin per-service schema (noidfield — identity is positional/composite-key, which matters below).system-prompt-configs.ts:202-211— compile-time flattening intoservices_provided.job-types.ts:75— the classifier's only multi-service awareness today: a narrow-to-one disambiguator, active only whenguidancenames the single service to isolate.- Notably: no live-call tool reads
system_prompt_configsat call time today. A grep acrosslib/tools/,lib/vapi/,lib/elevenlabs/finds zero tool-handler hits; the only call-path read is assistant provisioning (create-assistant.ts:224, country only). The KB reaches a call exclusively through prompt compilation. This proposal introduces the first call-time KB read — flagged explicitly because it's a new class of dependency (one indexed single-row select; latency notes in the verification section).
Proposed behavior — identify service → tool call → inject rich context
Enrich each job_types entry with the full corpus that service actually needs (follow-up questions, conditional logic, tech-facing notes — and, once the multiservice-booking-kb-exceptions ADR lands its Booking Groups decision, combinability becomes one more field of the same record). Keep all of it out of the compiled prompt: services_provided stays exactly as thin as today. Deliver it instead through a tool result at identification time — as soon as the caller has said what they need, before any gathering or booking flow. The tool accepts all n services the caller stated in one call, resolves each deterministically, and its result carries both the per-service rich context and whether any of the requested services may be grouped into a single appointment.
How the injected content actually reaches the model is an already-proven pattern, not new machinery: findCustomer conditionally weaves behavioral instructions into its tool message and mirrors them into data so the signal survives prose truncation — the retryMessage pattern, findCustomerTool.ts:1256-1292 (second, config-driven instance at :718-741; message-level test coverage at findCustomerTool.test.ts:1220-1240, plus downstream fixtures in resolve-confirmed-customer.test.ts:123,186,384 asserting the woven string round-trips through real tool output). getBookingInfo's result type already carries a free-text output?: string field used for exactly this kind of additive guidance (getBookingInformationByStAtAddV2Tool.ts:370, consumed via appendChecklistCorrectionMessage, usage at the tool's :785-796).
Where should the injection live? — layer choice
Three credible mechanisms. (Format mirrors the Booking Groups ADR's three-option comparison; this is the general-architecture sibling of that narrower decision.)
Option A — Extend getBookingInfo's result (transparent, classifier-keyed)
Inside the existing tool, immediately after getJobTypeFromTranscript resolves (helpers.ts:1324-1331), load the matched service's rich context and append it to the result message + mirror into data — the retryMessage / appendChecklistCorrectionMessage shape, verbatim.
- Pros: zero new tools, zero new agent decisions — injection is keyed to a deterministic code moment the platform already trusts. Multi-platform for free: the VAPI handler (
dispatch-config.ts:147) and the ElevenLabs handler (tool-handlers/index.ts:123) converge on the same core class, so one change covers both. - Cons: the timing is wrong for half the payload.
getBookingInfofires when the agent is ready to look up availability — after information gathering. Follow-up questions injected there arrive too late to guide gathering, and answers that change the job's shape (e.g. "is that one unit or two?") force a re-query. Worse, calls that never reach a booking flow (message-taking, pure questions, transfers) never fire the tool at all, so their service context never arrives.
Option B — A new, dedicated tool fired at service identification (getServiceContext)
A new tool the agent is instructed to call the moment the caller has said what they need — accepting n stated service needs in one call (an array of the agent's descriptions, one per stated need). Internally, per stated need: run the same determineJobTypeFromTranscript classifier against the team's configured list (deterministic matching — the agent triggers the lookup but never free-matches names; the classifier's existing guidance disambiguator at job-types.ts:75 already supports narrowing to one named service per invocation, so n parallel calls need zero classifier changes), then a new loadServiceContextForTeam(teamId, jobTypeRowIds) helper — shape copied from loadKbContextForTeam (generate-type-guidance.ts:409-424) over getSystemPromptConfigByTeamId (config-service.ts:139-152, select('*') — new jsonb fields need zero query changes) — returning only the matched services' rich entries, woven into message + data. When 2+ services resolve, the result additionally reports whether any of the requested services may be grouped into one appointment, read from the same per-team combinability data (booking_groups) the Booking Groups plan is building — so the agent knows one-visit-or-two the moment the services are identified, not at booking time.
- Pros: earliest possible injection — the follow-up corpus arrives while the agent is still gathering, which is where its value is. Works on every call shape, booking or not. The injected content is isolated in its own tool result (clean audit trail in
tool_calls: exactly which service's context entered which call, when). One standing-prompt instruction replaces N services' worth of standing logic — the trade is one line of static prompt for the removal of the whole static corpus. - Cons: reintroduces one agent decision — "recognize that a service was identified and call the tool" — the same class of tool-selection reliability question the Booking Groups ADR's C2 variant was dinged for. Mitigations: the trigger is broad and unconditional ("whenever the caller names or describes a service need"), not a judgment call; and the classifier inside stays deterministic, so a mis-timed call degrades to a no-op or a correct-but-early injection, never a wrong injection. Adds one LLM sub-call of latency (~1s at temperature 0 with a forced enum; same call the booking path already absorbs today) at a conversationally natural pause.
Option C — Platform-native retrieval (ElevenLabs KB / VAPI knowledge attachments)
Hand the rich corpus to each voice platform's own retrieval/KB feature and let the platform inject relevant chunks.
- Pros: no avoca-next tool plumbing.
- Cons: rejected on four grounds. (1) Two divergent, platform-owned implementations for one behavior Avoca must control. (2) Retrieval is similarity-based — non-deterministic, exactly the failure class this whole investigation traced (a rule surfacing on a call it doesn't belong to). (3) No audit trail equivalent to a
tool_callsrow. (4) It inverts the blueprint architecture: Avoca compiles its own prompts precisely so behavior is inspectable and versioned; outsourcing context selection to a vendor black box abandons that for the highest-stakes content we have.
Comparison at a glance
A — Extend getBookingInfo | B — Dedicated tool at identification | C — Platform-native retrieval | |
|---|---|---|---|
| Injection timing | Late (availability lookup) | At identification, pre-gathering | Uncontrolled |
| Covers non-booking calls | No | Yes | Yes |
| New agent decision required | None | One, broad-trigger, mis-fire-safe | None |
| Matching mechanism | Existing classifier (deterministic) | Same classifier (deterministic) | Vendor similarity search |
| Auditability of what entered context | Bundled into booking result | First-class (own tool_calls row) | None |
| Multi-platform cost | Free (shared core class) | One core + two thin adapters (existing pattern) | Two divergent integrations |
| Effort | Smallest | Moderate | Smallest to start, highest to trust |
Decision (2026-08-04): Option B. The deciding argument is timing: the whole value of the follow-up/conditional corpus is that it guides information gathering, and waiting for the booking flow is too late — the injection has to happen as soon as the caller has said what they need. B is the only option that delivers there, and it covers every call shape (booking or not). The one real cost (agent must fire the tool) is bounded because a missed or late call degrades safely, and the rules that must never depend on the agent remembering — combinability gating at commit time — are exactly the ones the Booking Groups ADR is already deciding how to enforce deterministically at the booking-tool layer. The two mechanisms are complementary, not redundant: getServiceContext informs early (including the "these can be grouped" signal in its result); that ADR (if its Option C is chosen) enforces the combine/split decision in code at booking regardless of what the conversation did with the information. A's loader is kept as a shared booking-time backstop: Phase 2 below threads the same loadServiceContextForTeam output into getBookingInfo's existing output field, so a call that somehow reached booking without a getServiceContext call still gets the matched service's rules before commit — the same belt-and-suspenders philosophy as retryMessage's message+data mirroring.
Relationship to in-flight work
Checked explicitly for overlap before scoping (both systems verified in code, 2026-08-04):
- Eric's scenario-facts mocking (Standard Test Suite V2): generated test cases carry canonical
scenario_facts; on a live test call,test-call-mock.tsresolves the active test call, pulls its facts (active_test_calls.scenario_facts,tool-call-mocks.ts:364), and interpolates them into per-case mock templates at tool-response time. This is structurally the closest existing relative of what's proposed — per-call lookup keyed to call identity, content delivered through tool responses — but it substitutes tool results on test calls only; it never injects guidance into real calls. No duplication. Two coordination points, both obligations on this proposal: (1)getServiceContextmust register amockToolNamelike every other tool so Eric's mock resolution covers it on test calls — otherwise simulated calls would read real team KB context and break scenario isolation; (2) once rich per-service entries exist, V2's case generation (which already generates from KB data) gets strictly better input. - Caleb's expectations → guardrails pipeline: team-level "Assistant Expectations" text is LLM-expanded into Hamming guardrail assertions with review-and-accept and staleness tracking (
hamming-auto-guardrails.ts,app/api/team/[teamId]/expectations/team-guardrails/route.ts). It generates test assertions at authoring time — not live-call context — so again no duplication. But its authoring UX is exactly the pattern Phase 3 should copy: LLM-draft the rich per-service entries (seeded from the team's existing module overrides and the 16-row exception sweep), human review-and-accept, staleness flag when the source material changes. And its output becomes the natural verification hook: a team's guardrails can assert "the agent asked the water-heater follow-ups" once those follow-ups are structured data instead of prose.
Explicitly not re-decided here: the multiservice-booking-kb-exceptions ADR (workbench plan .indusk/planning/multiservice-booking-kb-exceptions/adr.md, open between three options for the narrower combine/split decision). This document treats those two calls as its motivating case study and takes no position on that ADR's A/B/C choice. The touchpoint is structural: whatever Booking Groups builds, its combinability data becomes one field within this proposal's richer per-service records — sharing the same Phase-0 prerequisite below.
Implementation scope
Phase 0 — stable per-service identity (shared prerequisite)
job_types entries have no persisted id today — the editor's _id is a client-side crypto.randomUUID() re-minted per load and stripped on save. Anything that references a service from outside the array (rich-context lookup, Booking Groups' serviceIds) needs a server-assigned, stable id on businessInformationJobTypeSchema, backfilled lazily on save. Already identified as the Booking Groups plan's Phase 0; build once, shared.
Phase 1 — schema enrichment (data only, no behavior change)
- Extend
businessInformationJobTypeSchemawith optional rich fields — working set:follow_up_questions(ordered list),conditional_guidance(free text: the if/then corpus),tech_notes(tech-facing info the CSR-side agent may relay). All nullable/optional; zero migration needed beyond the jsonb column already existing. - Deliberately untouched:
generateServicesProvidedFromJobTypesand its column-inference — the compiledservices_providedblock must not grow. The bloat fix is that these fields never render at compile time. A lint-style unit test pins that: compiled output identical with and without rich fields populated.
Phase 2 — the tool + loader
loadServiceContextForTeam(teamId, jobTypeRowIds)— new helper next toloadKbContextForTeam, samegetSystemPromptConfigByTeamIdread (note: that precedent dropsbooking_overridein its parse step; the new parser must carry the rich fields through). Accepts multiple ids from day one; also readsbooking_groups(once the Booking Groups plan lands it) to compute same-group membership among the passed ids.- New
getServiceContextcore tool class following the established shared-core shape: one implementation, thin VAPI handler + dispatch-config entry, thin ElevenLabs handler +TOOL_HANDLERSentry, tool-catalog constant,mockToolNameregistration (test-call coverage, per the Eric coordination point above). Input schema: an array of stated service needs (the agent's description of each, min 1) — resolution is n paralleldetermineJobTypeFromTranscriptcalls, each using the existingguidancedisambiguator to narrow to its one stated need; no classifier schema change required. - Result assembly: matched services' rich entries woven into
message, mirrored intodata.serviceContext(theretryMessageshape). When 2+ services resolve, the result includes a grouping verdict per pair/set ("X and Y may be booked as one appointment" / "each service needs its own appointment"), frombooking_groups— explicit either way, so silence never reads as license to combine. No match → explicit "no additional service context configured" result, never an error (fail-open, same posture as the pre-call prefetch work). Absent/emptybooking_groups→ "each service needs its own appointment," the safe default the incident investigation argued for. - Thread the same loader's output into
getBookingInfo's existingoutput?: stringfield after its classifier resolves — the booking-time backstop. - One standing-prompt module line instructing the
getServiceContextcall on service identification (blueprint module change, versioned like any other).
Phase 3 — authoring + migration
- "Service details" expansion of the existing self-serve
job_typescard in the system-prompt editor. - LLM-assisted drafting seeded from each team's existing module override text (Caleb's review-and-accept + staleness pattern).
- Migration playbook for the 16 known exception-carrying rows: their service-specific content moves into the owning team's rich entries; the shared module text keeps only service-agnostic rules. (The interim wording fix already de-fanged the immediate clause; this phase removes the pattern's habitat.)
Explicitly out of scope
- No code in this document — design for review.
- The Booking Groups combine/split decision — owned by its open ADR, referenced above.
- Prompt-module rewrites beyond the one instruction line — which module text shrinks, and when, is per-blueprint editorial work that follows adoption, not a big-bang rewrite.
Verification recipe
- Compiled-prompt invariant (bloat): compile a pilot team's prompt before/after Phase 1 with rich fields fully populated — byte-identical output. Then after Phase 3 migration, re-compile and record the shrink (the measurable de-bloat number for this team).
- Injection correctness (unit):
getServiceContextwith one stated need naming service X returns X's rich entry and no other service's; with n stated needs it returns exactly n resolved entries plus a grouping verdict for the set — same-group ids report combinable, everything else reports "separate appointments," and an emptybooking_groupsconfig reports "separate appointments" (never silence).messageanddata.serviceContextcarry identical content (mirror invariant, same assertion style as theretryMessagecoverage). - Contamination regression (the two real calls, via Hamming): replay both incident scenarios as scripted voice test calls against a pilot team whose KB carries rich entries and whose module carries no combinability exception —
- water heater + AC panel → two separate booking flows, two
getBookingInfocalls; - heat pump + generator → two separate booking flows;
- one physical heat-pump unit with heating+cooling symptoms → still one appointment (the legitimate case, once expressed as that team's own rich-entry/Booking-Groups data rather than a fleet-wide clause). Assert on the
tool_callstrace (which service's context was injected, when) — not only the transcript — mirroring the two-surface validation pattern established by the simulation-transfer-mocking work.
- water heater + AC panel → two separate booking flows, two
- Latency budget: the new tool's end-to-end p95 on test calls (classifier sub-call + one indexed single-row select) stays inside the in-call tool budget already tolerated for
getBookingInfo's classifier today; the select adds single-digit ms against the classifier's ~1s. This is the first live-call read ofsystem_prompt_configs— watch it in the tool's span, and if it ever matters, the row is trivially cacheable per call. - Test-call isolation: a Hamming test call with a configured mock for
getServiceContextreturns the mock, not the real KB row (Eric-system integration check).
The broader pattern — why this generalizes
The bug that motivated this document is one instance of a pattern worth naming for Avoca engineering: static context is the default delivery mechanism for everything, so everything ends up delivered statically. When the only home for "logic that applies in situation X" is a prompt compiled before X is knowable, authors have two options — omit it, or make it always-present. Sixteen independent authoring decisions across 7 blueprints and 5 teams all chose always-present, and the model did what models do with always-present examples: generalized from them on calls where they didn't apply.
The counter-pattern is already half-established in the codebase, one lane at a time:
- Pre-call: the AutoOps prefetch work injects per-caller context as conversation-initiation variables — lazily, per call, at pickup.
- In-call, per-customer:
findCustomerreturns per-customer facts with behavioral guidance woven into the result — lazily, at lookup. - In-call, per-service: missing — this proposal.
Each lane replaces a "bake it in, just in case" decision with "fetch it at the moment it becomes relevant, keyed by something deterministic." The same shape extends naturally to per-CRM handling quirks, membership-tier rules, and per-location facts — anything currently written into standing modules because there was nowhere else to put it. The strategic claim of this document is not just that service context should be lazy; it's that the standing prompt should converge toward invariants — identity, tone, safety, flow control — with everything conditional delivered just-in-time through tools, where selection is deterministic, injection is auditable per call, and one team's special case can never leak into another call's reasoning.
Open questions
- Tool name and trigger phrasing —
getServiceContextis a working name; the standing-prompt trigger line needs the same wording rigor this investigation applied to the exception clause (it is itself always-present text). - Grouping-verdict boundary with the Booking Groups ADR — this tool reports grouping at identification time from the shared
booking_groupsdata; whether the booking layer additionally enforces it at commit (that ADR's Option C) is still that ADR's open decision. If it lands, both mechanisms read the same rows and cannot disagree; until it lands, the verdict here is the only in-call surface of the data. - Payload ceiling — rich entries are authored free text; a per-entry size budget (and an authoring-time warning) is probably needed so "lazy injection" doesn't become "lazy bloat."
- Which pilot team — team 3082 or 2744 are the natural candidates (real incident scenarios to replay), but both are live production teams; a staging replica with their config shape may be the right first target.