Skip to content

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:

SurfaceCode pathTriggered by
In-call (read)lib/vapi/tools/handlers/autoOps*.tslib/autoops/autoops.tsLLM invoking a Vapi tool mid-call
Post-call (write)lib/workflow/stages/{rescheduling,cancellations}/*-autoops.ts → same clientInngest 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 tests

Base 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

MethodAutoOps endpointUsed byNotes
getCustomersGET /clients/{clientId}/customersin-call lookupPhone + name search filters.
getJobs / getJobsPageGET /clients/{clientId}/jobsin-call lookup, post-call jobId resolutioncustomer 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.
getJobGET /clients/{clientId}/jobs/{jobId}post-call jobId validationUsed by getExistingAutoOpsJobIdFromTranscript to validate LLM-extracted jobIds before mutating.
reschedulePATCH /clients/{clientId}/jobs/{jobId}/reschedulepost-call reschedule leafIdempotency-Key header required for retry safety.
cancelPOST /clients/{clientId}/jobs/{jobId}/cancelpost-call cancel leaf400s for SMS-typed customers (permanent product policy, not a togglable flag). The leaf maps that to a graceful failure result.
getServicesGET /clients/{clientId}/servicessmoke + setupNot 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>.ts

Each handler follows the same shape:

  1. Parse + validate args from the Vapi tool-call payload.
  2. Resolve credentials via getAutoOpsLiveClient(teamId) (returns an authed AutoOps client + clientId + clientName).
  3. Make the AutoOps API call(s).
  4. 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 Supabase

What 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 customer filter 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

  1. Add the AutoOps client method in lib/autoops/autoops.ts (and types in lib/autoops/types.ts). Mirror the existing patterns: Authorization: Bearer, URL-encoded path segments, optional idempotencyKey for mutations.
  2. Add a handler in lib/vapi/tools/handlers/autoOps<Name>.ts. Use getAutoOpsLiveClient(teamId) for credentials. Return the standard vapiResult envelope.
  3. Register the handler in pages/api/vapi/tools/dispatch.ts (the dispatcher entry point) and lib/voice-assistants/agents/agent-factory.ts (the agent toolMap).
  4. Add the tool to lib/voice-assistants/available-tools.ts with its tool name string. Per-tool URL is built via buildUrl() automatically.
  5. Add unit tests covering happy path + error classification.
  6. 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.
  • 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 the server.url priority hierarchy that determines where tool dispatches actually go.
  • how-to/local-dev-env.md — FDE local-dev loop. Set NGROK_BASE_URL to route AutoOps tool dispatches to localhost.
  • gotchas.md — Architectural Debt entry on SMS-cancel disallowed; Footgun F1 on the workflow-endpoint multiplexer.