Bolna Dispositions (Post-Call Extractions)
Dispositions turn raw transcripts into structured data after every call. Each disposition asks one question; results land in extracted_data on the execution payload and in the webhook delivery.
Hierarchy
Agent
└── Extractions (feature)
└── Category (e.g. "Lead Quality")
└── Disposition (e.g. "Call Outcome")
Category groups results visually. Disposition is the unit — one question, one answer set.
When to add a disposition
| Use case | Field name | Answer type |
|---|---|---|
| Call outcome | Call Outcome |
Pre-defined: interested / not_interested / follow_up / wrong_number |
| Lead qualification (BANT) | Budget, Authority, Need, Timeline |
Mixed — free text + Yes/No |
| Appointment booked | Appointment Date, Appointment Time |
Free text with timestamp / text validation |
| Sentiment | Customer Sentiment |
Pre-defined: positive / neutral / negative |
| Agent handover required | Handover Needed |
Pre-defined: Yes / No |
| Compliance | Disclosure Read, Consent Captured |
Pre-defined: Yes / No |
| Contact info captured | Email, Alt Phone |
Free text with email / regex validation |
| Open-ended summary | Main Concern, Reason for Cancellation |
Free text |
Endpoints
| Endpoint | Purpose |
|---|---|
GET /dispositions/ |
List all dispositions in the account (optionally scoped to an agent) |
GET /dispositions/{id} |
Get a single disposition |
POST /dispositions/ |
Create one disposition, optionally linking to agents |
POST /dispositions/bulk |
Create N dispositions for an agent atomically |
PUT /dispositions/{id} |
Update a disposition (copy-on-write aware) |
DELETE /dispositions/{id} |
Delete a disposition |
POST /v2/agent/{agent_id}/dispositions/test |
Test all of an agent's dispositions against a transcript |
Auth: Authorization: Bearer $BOLNA_API_KEY. JSON body for all POST/PUT.
Disposition object
{
"name": "Call Outcome",
"question": "What was the outcome of the call?",
"system_prompt": "You are analyzing a sales call transcript.",
"category": "Lead Quality",
"model": "gpt-4.1-mini",
"is_subjective": true,
"is_objective": true,
"subjective_type": "text",
"subjective_type_config": null,
"objective_options": [
{ "value": "interested", "condition": "Customer expressed genuine interest and agreed to a next step" },
{ "value": "not_interested", "condition": "Customer declined all proposals" },
{ "value": "follow_up", "condition": "Customer asked to be contacted later" }
],
"agent_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"]
}
| Field | Type | Notes |
|---|---|---|
name |
string | Display name in extracted_data. |
question |
string | The prompt the LLM evaluates against the transcript. Be specific. |
system_prompt |
string | null | Optional system context for the evaluation LLM. |
category |
string | Groups outputs. Default "General". |
model |
string | Default gpt-4.1-mini. Use a stronger model for high-stakes extractions. |
is_subjective |
bool | Enables subjective free-text response. |
is_objective |
bool | Enables objective pre-defined selection. |
subjective_type |
string | Free-text constraint. See below. |
subjective_type_config |
object | Required when subjective_type == "regex". |
objective_options |
array | Required when is_objective is true. |
agent_ids |
array | Agents this disposition is linked to. |
At least one of is_subjective or is_objective must be true.
Free-text validation types
subjective_type |
What it validates | Example value | Notes |
|---|---|---|---|
text |
Anything | "Customer was happy with the demo" |
Default. No validation. |
timestamp |
ISO 8601 | "2026-04-08T14:30:00" |
Parsed and validated. |
numeric |
Int or decimal | "42", "3.14" |
|
boolean |
Exactly true or false |
"true" |
|
email |
Valid RFC email | "user@example.com" |
|
regex |
Matches subjective_type_config.pattern |
"1234567890" |
Add pattern + optional description in subjective_type_config. |
When validation fails, the original LLM response is preserved in subjective and validation.is_valid is false. No data is lost — the consumer decides whether to use it.
Example with regex (10-digit phone):
{
"subjective_type": "regex",
"subjective_type_config": {
"pattern": "^\\d{10}$",
"description": "10-digit phone number"
}
}
Objective options
{
"value": "interested",
"condition": "Customer expressed genuine interest and agreed to a next step",
"sub_options": []
}
sub_options is recursive and supports hierarchical classifications (e.g. interested → enterprise / pro / starter).
Output format on executions
{
"execution_id": "abc123",
"status": "completed",
"transcript": "...",
"extracted_data": {
"Lead Quality": {
"Call Outcome": {
"subjective": "Customer expressed strong interest and asked about enterprise pricing.",
"objective": "interested",
"confidence": 0.92,
"confidence_label": "High",
"reasoning_subjective": "Customer asked about enterprise pricing and requested a demo.",
"reasoning_objective": "Customer explicitly expressed interest and agreed to a next step.",
"validation": null
}
},
"Contact Info": {
"Customer Email": {
"subjective": "user@example.com",
"objective": null,
"confidence": 0.95,
"confidence_label": "High",
"reasoning_subjective": "Customer clearly provided their email address during the call.",
"reasoning_objective": null,
"validation": { "is_valid": true, "expected_type": "email" }
}
}
}
}
| Field | Notes |
|---|---|
subjective |
Free-text response. "" when nothing found, null when is_subjective is false. |
objective |
Selected pre-defined value, or null. |
confidence |
0.0 – 1.0. |
confidence_label |
"High" (≥0.8), "Medium" (≥0.5), "Low" (<0.5). |
reasoning_subjective / reasoning_objective |
LLM's explanation per answer type. Useful for audit + debugging. |
validation |
Present only when subjective_type is typed (not plain text). |
Same payload arrives via webhook. See ../references/execution-payload.md.
Create one disposition
curl --request POST https://api.bolna.ai/dispositions/ \
--header "Authorization: Bearer $BOLNA_API_KEY" \
--header "Content-Type: application/json" \
--data @- <<'JSON'
{
"name": "Call Outcome",
"question": "What was the outcome of the call?",
"category": "Lead Quality",
"is_subjective": true,
"is_objective": true,
"subjective_type": "text",
"objective_options": [
{ "value": "interested", "condition": "Customer expressed genuine interest and agreed to a next step" },
{ "value": "not_interested", "condition": "Customer declined all proposals" },
{ "value": "follow_up", "condition": "Customer asked to be contacted later" }
],
"agent_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"]
}
JSON
Returns the full disposition object with id. Save it if you intend to update later.
Bulk create (recommended for new agents)
Add a full analytics suite atomically — either all succeed or none do:
curl --request POST https://api.bolna.ai/dispositions/bulk \
--header "Authorization: Bearer $BOLNA_API_KEY" \
--header "Content-Type: application/json" \
--data @assets/sample_bulk_dispositions.json
The asset file is a complete sales-call analytics pack: Call Outcome, Customer Sentiment, Budget mentioned, Decision Maker, Handover Needed, Customer Email.
Test before going live
Run all of an agent's dispositions against a transcript and inspect what comes out:
curl --request POST "https://api.bolna.ai/v2/agent/$AGENT_ID/dispositions/test" \
--header "Authorization: Bearer $BOLNA_API_KEY" \
--header "Content-Type: application/json" \
--data '{
"transcript": "Agent: Hi, how can I help? Customer: I am interested in your enterprise plan. Agent: Great — what email should I send the proposal to? Customer: priya at example dot com.",
"call_date": "2026-05-19T10:00:00Z"
}'
Response is the same shape as a real execution's extracted_data. Iterate on dispositions until results match what you'd want from production calls.
Updates and the copy-on-write rule
Scoped update (pass agent_id in the body or URL):
| Disposition state | Behaviour | Status |
|---|---|---|
| Exclusive to this agent | Edited in place | 200 OK (id unchanged) |
| Shared with other agents | New private copy created, agent re-linked to copy | 201 Created (new id) |
If you store disposition IDs in your own database, update the stored ID when you see 201. The old ID still exists — it just no longer belongs to your scoped agent.
Unscoped update (no agent_id):
- Admins: edits any disposition in place.
- Non-admins: only edits dispositions they own.
Authorization quirks
- Regular users can
DELETEonly dispositions they created (created_bymatches their user ID). - Admins can delete any disposition.
- Historical executions that already contain a disposition's output are unaffected by edits or deletion — only future calls change.
Going deeper
| File | Contents |
|---|---|
references/answer-types.md |
Free Text vs Pre-defined, validation patterns, when to use each. |
references/copy-on-write.md |
Worked walkthrough of the 200 vs 201 update flow, with code. |
assets/sample_bulk_dispositions.json |
Drop-in sales-call analytics pack — 6 dispositions, two categories. |
assets/sample_transcripts.json |
Three transcripts (sales, support, appointment) for testing. |
scripts/create_disposition.py |
Wraps POST /dispositions/ with flags. |
scripts/bulk_create_dispositions.py |
Wraps POST /dispositions/bulk from a JSON file. |
scripts/test_dispositions.py |
Wraps POST /v2/agent/{id}/dispositions/test for sample transcripts. |
Legacy: gpt_assistants.custom_questions
Older agents (and some agents migrated from the v1 dashboard) carry their post-call extraction logic inline on the agent itself, not as separate disposition objects. You'll see this when GET /v2/agent/{id} returns:
{
"dispositions": null,
"gpt_assistants": [
{
"assistant_id": "asst_f4sCpG4zASXIls0FMl7PkMMt",
"custom_questions": "1. interested:\n[- What to Extract: User interest in becoming an expert\n- Expected Response: Yes or No]\n\n2. reason_not_interested:\n[- What to Extract: Reason stated by the user...\n- Expected Response: Short reason]\n\n3. full_name:\n..."
}
]
}
| Aspect | Legacy gpt_assistants |
Modern dispositions |
|---|---|---|
| Where it lives | Inline on the agent, single free-text string | Separate disposition objects linked via agent_ids |
| Format | Numbered list, free-text "what to extract" + "expected response" hint | Structured: name, question, objective_options[], subjective_type |
| Validation | None — the LLM returns whatever it interprets | Typed (timestamp, email, regex, etc.) with validation.is_valid flag |
| Confidence | No | Yes — confidence + confidence_label |
| Reusable across agents | No — copied per agent | Yes — one disposition can link to N agents |
| Where the result lands | Same extracted_data field on the execution |
Same extracted_data field on the execution |
Both systems coexist. If dispositions is set, it runs. If dispositions is null but gpt_assistants[0].custom_questions has content, that runs instead. The output structure on the execution is the same shape either way.
Migration: when you ask the user to "add dispositions" to a legacy agent, you typically:
- Parse the existing
custom_questionsstring — one numbered item per intended disposition. - Convert each item into a structured disposition (
POST /dispositions/). - Optionally clear
gpt_assistantsonce the new dispositions are linked, so it's unambiguous which system is producing the output.
You don't need to migrate to use modern features — but you can't get confidence scores, typed validation, or shared dispositions without it.
Common gotchas
- Vague questions yield low confidence. "How was the call?" → noisy. "Did the customer agree to a follow-up meeting? If yes, on what date?" → clear.
objective_options.conditionis the LLM's selection rubric. Write it as if instructing a human reviewer.- Don't pile many concepts into one disposition. Split "lead quality and next step" into two dispositions — outcome + action.
- Pick the right answer type. Categorical =
is_objective. Open-ended =is_subjective. Both = both flags true (lots of dispositions benefit from both). - Confidence ≥ 0.8 is generally trustworthy. Below 0.5 → review manually. Build CRM routing around
confidence_label, not raw transcript heuristics. agent_idsis the linkage. A disposition withoutagent_idsexists but doesn't run on any agent. Bulk-create is the easiest way to ensure every new disposition is linked.
See also
get-executions— fetchingextracted_dataafter calls.setup-webhook— receiving extractions in real time.create-agent— top-level agent config (Extractions is a feature of the Analytics tab).../references/execution-payload.md— shape that containsextracted_data.