Appearance
AutoOps integration
Canonical reference for how Avoca calls AutoOps. Covers the API client, auth, the in-call tool handler pattern, the customer→jobs lookup shape, and the now-vestigial mirror sync.
Scope
This page is the in-call integration reference: how the agent invokes AutoOps mid-call (lookup customer, get jobs). For the post-call dispatch chain (reschedule + cancel side-effects), see post-call-dispatch-chain.md. For credentials + the direct-API test path, see dev-env-access.md.
What AutoOps is, here
EAS (Ponderosa) is the only AutoOps tenant Avoca currently integrates with. AutoOps is the shop's CRM-equivalent — customers, vehicles, jobs (appointments), services, availability. Avoca's voice assistant reads AutoOps state mid-call (to identify the caller and look up their appointments) and writes to it post-call (reschedule, cancel — though SMS-typed customers can't cancel via API, so cancel always 400s for the EAS pilot's customer category — see gotchas.md Architectural Debt).
Two integration surfaces:
| Surface | Code path | Triggered by |
|---|---|---|
| In-call (read) | lib/vapi/tools/handlers/autoOps*.ts → lib/autoops/autoops.ts | LLM invoking a Vapi tool mid-call |
| Post-call (write) | lib/workflow/stages/{rescheduling,cancellations}/*-autoops.ts → same client | Inngest workflow firing on end-of-call-report |
Both surfaces use the same AutoOps client class and the same auth path. They differ in where they live in the call lifecycle and what they're allowed to do (in-call is read-only by convention; mutations belong in post-call to enable retry-safety via Inngest).
The AutoOps API client
apps/web/lib/autoops/autoops.ts — `AutoOps` class
apps/web/lib/autoops/types.ts — request/response types
apps/web/lib/autoops/__tests__/ — unit testsBase URL + auth
- Base URL:
https://api.autoops.com/v1(hardcoded in the client; not env-driven). - Auth:
Authorization: Bearer <apiKey>. The apiKey for the EAS pilot is the shop's plaintext API key, provisioned by AutoOps.
Avoca's app reads encrypted AutoOps credentials from Supabase (autoops_team_configs joined to autoops_credentials) and decrypts them via WEBHOOK_API_KEY_ENCRYPTION_KEY. For FDE work this decryption layer is bypassed — Kareem provided the plaintext apiKey directly (see dev-env-access.md). The test path is direct curl against the AutoOps API; the Avoca decryption path runs only in the deployed app.
Endpoint surface used today
| Method | AutoOps endpoint | Used by | Notes |
|---|---|---|---|
getCustomers | GET /clients/{clientId}/customers | in-call lookup | Phone + name search filters. |
getJobs / getJobsPage | GET /clients/{clientId}/jobs | in-call lookup, post-call jobId resolution | customer filter (comma-separated IDs) added by AutoOps 2026-05-08 — enabled the migration off the mirror. expand: ['vehicle'] is required to get full vehicle objects rather than ids. |
getJob | GET /clients/{clientId}/jobs/{jobId} | post-call jobId validation | Used by getExistingAutoOpsJobIdFromTranscript to validate LLM-extracted jobIds before mutating. |
reschedule | PATCH /clients/{clientId}/jobs/{jobId}/reschedule | post-call reschedule leaf | Idempotency-Key header required for retry safety. |
cancel | POST /clients/{clientId}/jobs/{jobId}/cancel | post-call cancel leaf | 400s for SMS-typed customers (permanent product policy, not a togglable flag). The leaf maps that to a graceful failure result. |
getServices | GET /clients/{clientId}/services | smoke + setup | Not currently called from in-call or post-call paths. |
All jobId segments are URL-encoded by the client (defensive against weird LLM-produced ids walking the URL space). All mutating methods accept an idempotencyKey parameter; it's stamped onto the Idempotency-Key header so Inngest retries dedupe server-side.
Error classification
AutoOpsApiError carries status and a JSON body. lib/autoops/autoops.ts exports isCustomerHandleable4xx(error), used by the post-call dispatcher to distinguish:
- Handleable 4xx (job not found, customer not found, SMS-cancel disallowed) → graceful failure result returned, side-effects continue (e.g., the "shop will follow up" email path)
- 5xx or unknown 4xx → re-thrown so Inngest retries
In-call handlers use the same client but generally catch all errors and return a success: false shape to the LLM rather than throwing — the LLM should keep the conversation going even if AutoOps is unreachable.
In-call tool handler pattern
apps/web/lib/vapi/tools/handlers/autoOps<Action>.tsEach handler follows the same shape:
- Parse + validate args from the Vapi tool-call payload.
- Resolve credentials via
getAutoOpsLiveClient(teamId)(returns an authedAutoOpsclient +clientId+clientName). - Make the AutoOps API call(s).
- Return a
vapiResult(toolCallId, { success, ...data, message })envelope.
The two handlers J migrated:
autoOpsLookupCustomer
Inputs: phoneNumber, nameSearch (one or both). Output: { success, clientId, clientName, customers, message }.
Resolves a caller's identity to one or more AutoOps Customer rows. Uses the getCustomers endpoint. Pre-Plan-J: had a mirror-first short-circuit that read from the local sync. Removed — always goes direct.
autoOpsGetJobs
Inputs: customer IDs (typically one or more, from autoOpsLookupCustomer's result). Output: { success, clientId, clientName, customers, vehicles, jobs, message }.
Returns the customer's jobs and vehicles for use by the agent. Calls getJobs({ customer: ids.join(','), expand: ['vehicle'] }) post-J. Vehicles must be deduped in-handler — a customer with multiple jobs may share a vehicle across them, and the response shape collapses to one entry per vehicle.
Per-tool URL: priority 1
Each tool's server.url is built at agent-build time by FunctionTool.getVapiOpenAIModelTool calling buildUrl(this.path). buildUrl reads process.env.NGROK_BASE_URL (defaults to https://app.avoca.ai). This is priority 1 in Vapi's URL resolution stack — see vapi-squads.md. FDE local-dev sets NGROK_BASE_URL=<tunnel> to route tool dispatches to localhost — see how-to/local-dev-env.md.
The customer → jobs lookup pattern
The in-call agent identifies the caller in two phases:
1. autoOpsLookupCustomer(phoneNumber, nameSearch?)
→ returns one or more AutoOps Customer objects
→ if none found, agent asks for a different phone number (Plan K's prompt scope)
→ if multiple found (e.g., shared phone), agent disambiguates by name
2. autoOpsGetJobs(customerId(s))
→ returns the customer's upcoming/recent jobs + their vehicles
→ agent uses this to confirm "do you mean your appointment for the Civic on July 23?"Why this is two tools, not one composite: the agent often only needs the customer (e.g., for non-job questions like "what's my account on file?"). And when the customer can't be uniquely identified, the agent needs to interact with the caller before asking about jobs. Two-step lets the LLM control the flow.
The mirror sync (vestigial post-J)
apps/web/lib/autoops/sync/ — sync infra (cron + lookups)
└── lookup.ts — read helpers (lookupAutoOpsMirrorCustomers, etc.)
└── orchestrator.ts — Inngest cron: pulls AutoOps state into SupabaseWhat it was
Before AutoOps shipped the customer filter on GET /clients/{clientId}/jobs (2026-05-08), looking up "all jobs for customer X" required either:
- Fetching all jobs for the client and filtering client-side (N+1 latency, expensive at scale)
- Or pre-syncing all jobs into Avoca's Supabase (with a customer FK) and reading from there
Avoca chose the latter. The mirror sync runs every 5 minutes via Inngest cron, pulls AutoOps customers + jobs + vehicles into mirror tables, and the in-call lookup handlers used to read from those tables first.
Why it's dead weight now
- AutoOps's
customerfilter makes the direct API path fast enough. - Mirror data is stale by some sync-window (5 min when healthy, longer when not).
- Cursor staleness has caused real outages (PR #9980 incident, 2026-05-06).
- Every AutoOps schema change required a mirror migration — duplicated maintenance surface.
What's left to do
- Plan J (this PR) removed the in-call lookup handlers' reads from the mirror.
- Plan M (
turn-off-sync-cron, follow-up) is the one-line change to disable the Inngest cron. Lands once we re-confirm no other readers via grep. - After M, the
lib/autoops/sync/directory can be deleted entirely (cron + helpers + tests + Supabase mirror tables). That's a separate cleanup PR with no production impact since nothing reads from it.
Adding a new AutoOps tool
- Add the AutoOps client method in
lib/autoops/autoops.ts(and types inlib/autoops/types.ts). Mirror the existing patterns:Authorization: Bearer, URL-encoded path segments, optionalidempotencyKeyfor mutations. - Add a handler in
lib/vapi/tools/handlers/autoOps<Name>.ts. UsegetAutoOpsLiveClient(teamId)for credentials. Return the standardvapiResultenvelope. - Register the handler in
pages/api/vapi/tools/dispatch.ts(the dispatcher entry point) andlib/voice-assistants/agents/agent-factory.ts(the agent toolMap). - Add the tool to
lib/voice-assistants/available-tools.tswith its tool name string. Per-tool URL is built viabuildUrl()automatically. - Add unit tests covering happy path + error classification.
- If the tool mutates AutoOps state, do NOT register it as an in-call tool — it belongs in a post-call workflow stage. See
post-call-dispatch-chain.md.
Related pages
dev-env-access.md— credentials + direct-API smoke recipe.post-call-dispatch-chain.md— post-call AutoOps mutations (reschedule, cancel).in-call-sequence.md— runtime turn-by-turn loop showing where AutoOps tool dispatches fit.vapi-squads.md— Vapi squad architecture and theserver.urlpriority hierarchy that determines where tool dispatches actually go.how-to/local-dev-env.md— FDE local-dev loop. SetNGROK_BASE_URLto route AutoOps tool dispatches to localhost.gotchas.md— Architectural Debt entry on SMS-cancel disallowed; Footgun F1 on the workflow-endpoint multiplexer.