Configure the AI to return predictable, structured data after every call so your integration can act on it without parsing free-form transcripts.
How it works
When you attach a JSON Schema to a Voice Agent, Volume Reach runs a second LLM pass after the call completes:
- Call ends → transcript is saved.
- Volume Reach sends the transcript + your schema to OpenAI using its function-call API.
- OpenAI returns a structured object matching your schema exactly.
- The object is written to the Call record as
extractionand delivered in thecall.completedwebhook.
Failures (e.g. OpenAI rate-limit during a burst) are captured in extraction_error on the same webhook — your handler can fall back to reading the transcript manually.
Model: gpt-4.1-mini by default (fast, cheap, sufficient for 2–3 minute transcripts). Cost is bundled into aiCostUsd on the call record.
Authoring the schema
Pass a standard JSON Schema object to POST /agents/:id/extraction-schema:
curl -X POST https://api.volumereach.com/api/v1/public/agents/va_abc/extraction-schema \
-H "Authorization: Bearer vr_live_..." \
-H "Content-Type: application/json" \
-d '{
"schema": {
"type": "object",
"properties": {
"interested": { "type": "boolean", "description": "Did the lead express interest?" },
"budgetRange": {
"type": "string",
"enum": ["under_5k", "5k_to_15k", "15k_to_50k", "50k_plus", "not_mentioned"]
},
"timelineMonths": {
"type": "integer",
"description": "When they want the work done, in months from today. 0 = ASAP. null = not mentioned."
},
"appointmentDateTimeISO": {
"type": "string",
"format": "date-time",
"description": "Confirmed appointment in ISO-8601 UTC, or null if not confirmed."
},
"contactEmail": { "type": "string" },
"objections": {
"type": "array",
"items": { "type": "string" },
"description": "Concerns the lead raised (e.g. price, timing, spouse approval)."
}
},
"required": ["interested"]
}
}'
Pass { "schema": null } to disable extraction for an agent.
Best-practice guidelines
1. Use description liberally
Descriptions are the LLM's instructions for each field. The more specific you are, the better the extracted value.
"budgetRange": {
"type": "string",
"enum": ["under_5k", "5k_to_15k", "15k_to_50k", "50k_plus", "not_mentioned"],
"description": "The lead's stated budget range. Map estimates carefully: \"around ten grand\" -> 5k_to_15k. Use not_mentioned if they avoided the question."
}
2. Prefer enums over free-form strings
enum constrains output to a known set → easier downstream routing. Without enum, the LLM might return "mid-range", "affordable", "cheap" for the same budget level.
3. Use null by including types in a union
To allow a field to be absent, add "null" to the type union:
"contactEmail": { "type": ["string", "null"] }
4. Keep required short
Only mark fields as required when you truly can't act without them. Everything required is forced to have a value, even if the call didn't cover it — the LLM will hallucinate something plausible. For optional fields, allow null.
5. Test with real transcripts before going live
Open a recent call in the Volume Reach app → copy the transcript → paste into the OpenAI playground with your schema as a function parameter. Verify the LLM fills the fields correctly before saving.
Schema structure rules
The following are enforced at save time (you'll get a 400 invalid_extraction_schema if violated):
schemamust be a JSON object (not an array or primitive).- Top-level
typemust be one of:object,string,number,integer,boolean,array,null. properties(when present) must be an object.required(when present) must be an array of strings.- For
type: "object", every name inrequiredmust be present inproperties. - Total serialized schema size ≤ 32 KB.
OpenAI's own validation runs at call completion — invalid schemas that pass our structural check will surface as extraction_error rather than blocking the save, so complex validation issues are caught in the webhook payload.
Example: booking an appointment
Minimum schema to drive a Google Calendar booking flow:
{
"type": "object",
"properties": {
"interested": { "type": "boolean" },
"wantsAppointment": { "type": "boolean" },
"appointmentDateTimeISO": {
"type": ["string", "null"],
"format": "date-time",
"description": "Confirmed slot in ISO-8601. Null if not confirmed."
},
"appointmentDurationMinutes": {
"type": ["integer", "null"],
"description": "Length of the appointment. Default 60 minutes for site visits."
},
"contactEmail": { "type": ["string", "null"] },
"notes": {
"type": "string",
"description": "Freeform notes for the sales rep — reference property details, concerns, urgency signals."
}
},
"required": ["interested", "wantsAppointment"]
}
Your webhook handler:
if (data.extraction.interested && data.extraction.wantsAppointment && data.extraction.appointmentDateTimeISO) {
await googleCalendar.events.insert({
calendarId: "primary",
requestBody: {
summary: `Site visit: ${data.contact.name}`,
start: { dateTime: data.extraction.appointmentDateTimeISO },
end: { dateTime: addMinutes(data.extraction.appointmentDateTimeISO, data.extraction.appointmentDurationMinutes ?? 60) },
attendees: data.extraction.contactEmail ? [{ email: data.extraction.contactEmail }] : undefined,
description: data.extraction.notes,
},
});
}
Cost + latency
- Typical extraction adds ~1–3 seconds to call completion before the webhook fires.
- Typical extraction cost is < $0.001 per call (rolled into
aiCostUsdon the call record). - Transcripts longer than 30 000 characters are truncated before extraction (the model has a 128K context window, but we cap to bound cost and latency for unusual outliers).
Failure modes
| Situation | Behavior |
|---|---|
Agent has no extractionSchema configured | No extraction runs; webhook extraction field is null. |
| Transcript is empty (e.g. no-answer call) | No extraction runs; extraction is null. |
| OpenAI returns 429 (rate limit) | Up to 3 retries with 1–3 second backoff before surfacing extraction_error. |
| OpenAI returns 5xx (transient outage) | Up to 3 retries. |
| OpenAI returns 400 (invalid schema / content policy) | No retry; extraction_error contains the message. |
| OpenAI returns malformed JSON | extraction_error: "Failed to parse extraction JSON: ...". Very rare — report to support with the call_id if it happens. |
Extraction is considered non-critical: failures never block the call from completing or the webhook from firing. Your integration should handle extraction === null and extraction_error !== null gracefully.