# Two-Way Transfer Call — Complete Technical Reference
**Project:** MyBuddy / Kalimera
**Branch:** feature/forwardcall_func
**Last Updated:** 2026-03-19
---
## Table of Contents
1. [What Is Two-Way Transfer?](#1-what-is-two-way-transfer)
2. [System Components Overview](#2-system-components-overview)
3. [Database Tables](#3-database-tables)
4. [Agent Configuration](#4-agent-configuration)
5. [Complete Call Flow — Step by Step](#5-complete-call-flow--step-by-step)
6. [API Reference](#6-api-reference)
7. [Scenarios & Examples](#7-scenarios--examples)
8. [Decision Logic — How the System Picks the Next Action](#8-decision-logic--how-the-system-picks-the-next-action)
9. [SIP REFER Mode](#9-sip-refer-mode)
10. [Multi-Leg Call (Resume AI)](#10-multi-leg-call-resume-ai)
11. [Idempotency](#11-idempotency)
12. [Time-of-Day Scheduling](#12-time-of-day-scheduling)
13. [Trunk Switching](#13-trunk-switching)
14. [Recording Control](#14-recording-control)
15. [Monitoring & Debugging](#15-monitoring--debugging)
16. [Quick Reference Card](#16-quick-reference-card)
---
## 1. What Is Two-Way Transfer?
**Two-Way Transfer** is the ability for a Voice AI agent (bot) to hand off an active phone call to a human agent (or another number), and — if that transfer fails — to **return the caller back to the AI** rather than hanging up.
### The Core Problem It Solves
Without two-way transfer:
- AI decides to transfer → dials human agent → human doesn't answer → call dies.
- Caller is left with nothing.
With two-way transfer:
- AI decides to transfer → dials human agent → human doesn't answer → **AI resumes the call**, knows what happened, and can handle the situation gracefully.
### Key Capabilities
| Capability | Description |
|---|---|
| Multiple phone numbers | Try number 1, then 2, then 3 in order |
| Per-number retry logic | Retry the same number or skip to next on BUSY/NO ANSWER |
| Trunk switching | Auto-switch from primary to backup SIP trunk on network issues |
| Fallback to AI | Return caller to AI agent if all transfers fail |
| SIP REFER mode | Transfer via SIP protocol signal instead of bridge |
| Time-of-day scheduling | Only transfer during business hours |
| Resume with context | AI resumes knowing transfer failed, how many attempts, last dialed number |
| Idempotent outcomes | Safe to retry — same (call, attempt) always returns same decision |
---
## 2. System Components Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ CALLER (PSTN) │
└───────────────────────────────┬─────────────────────────────────┘
│ Inbound call
▼
┌─────────────────────────────────────────────────────────────────┐
│ PBX / ASTERISK │
│ - Handles SIP trunks │
│ - Runs AGI scripts (dials, reports outcomes) │
│ - Reads instructions from MyBuddy API │
└──────────┬────────────────────────────────────────┬────────────┘
│ Audio/WebSocket │ HTTP (AGI)
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────┐
│ VOICE AI AGENT │ │ MYBUDDY API │
│ (Orchestrator) │◄────────────►│ - Transfer metadata │
│ - STT / TTS │ REST calls │ - Transfer outcome engine │
│ - LLM (Claude etc.) │ │ - Resume context │
│ - Decides to │ │ - Session state DB │
│ transfer │ └──────────────────────────────┘
└──────────────────────┘ │
▼
┌──────────────────────────┐
│ POSTGRESQL DATABASE │
│ - transfer_sessions │
│ - transfer_outcomes │
│ - conversations │
│ - forward_numbers │
└──────────────────────────┘
```
---
## 3. Database Tables
### 3.1 `kalimera_forward_numbers`
Stores phone numbers pre-configured by the tenant for call forwarding.
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| tenant_id | UUID | Tenant owner |
| phone_number | String | E.164 format (e.g., `+1234567890`) |
| dialplan_id | UUID | Associated dialplan |
| created_at | DateTime | Creation timestamp |
| updated_at | DateTime | Last update timestamp |
---
### 3.2 `kalimera_transfer_sessions`
Tracks the live state of a transfer in progress.
**One row per conversation** (unique on `conversation_id`).
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| conversation_id | String | Links to the active call |
| tenant_id | UUID | Tenant owner |
| agent_id | UUID | AI agent handling the call |
| event_type | String | Always `"forward_number"` |
| ring_timeout | Integer | Seconds before NOANSWER |
| max_retries | Integer | Max total dials on a number |
| retry_delay | Integer | Seconds to wait between retries |
| fallback_action | String | `"resume_ai"` or `"hangup"` |
| phone_numbers | JSONB | Full list of numbers + rules |
| primary_trunk_id | UUID | SIP trunk for attempt 1 |
| backup_trunk_id | UUID | Backup trunk (if any) |
| current_number_index | Integer | Which number we are on (0-based) |
| current_retry_count | Integer | How many times current number dialed |
| total_attempts | Integer | Grand total of all dial attempts |
| trunk_switched | Boolean | Whether we already switched trunk |
| is_active | Boolean | True while transfer is ongoing |
| final_status | String | Populated when session closes |
---
### 3.3 `kalimera_transfer_outcomes`
Stores the result of **every dial attempt** (idempotent record).
**Unique on `(conversation_id, attempt)`** — same input always returns same cached response.
| Column | Type | Description |
|---|---|---|
| id | UUID | Primary key |
| conversation_id | String | Links to the call |
| tenant_id | UUID | Tenant owner |
| attempt | Integer | Attempt number (1, 2, 3...) |
| dialed_number | String | Number that was dialed |
| dialstatus | String | PBX result: `ANSWER`, `BUSY`, `NOANSWER`, etc. |
| hangupcause_q850 | Integer | Q.850 hangup cause code |
| tech_cause | String | SIP cause string |
| hangup_source | String | Who hung up |
| pbx_timestamp | DateTime | PBX-side timestamp |
| decision_action | String | What the system decided: `retry_same`, `dial_next`, etc. |
| decision_number | String | Next number to dial |
| decision_trunk | UUID | Next trunk to use |
| decision_timeout_sec | Integer | Timeout for next dial |
| decision_wait_ms | Integer | Wait before dialing |
| decision_message | String | Human-readable decision explanation |
---
### 3.4 `kalimera_conversations` (enhanced)
Added column: `root_conversation_id`
| Column | Type | Description |
|---|---|---|
| root_conversation_id | String | Original call's conversation ID — links all legs |
| call_type | String | `"inbound"`, `"outbound"`, or `"resume_ai"` |
---
## 4. Agent Configuration
The transfer behavior is defined in the AI agent's configuration JSON under `eventNodes`.
### 4.1 Full Configuration Structure
```json
{
"eventNodes": [
{
"eventType": "forward_number",
"phone_numbers": [
{
"phone_number": {
"phone_number": "+12025550101"
},
"sip_trunk": {
"id": "uuid-of-primary-trunk",
"friendly_name": "Primary US Trunk"
},
"rules": {
"ring_timeout": 30,
"retry": "retry",
"busy": "next_number",
"no_answer": "next_number",
"unavailable": "switch_trunk"
}
},
{
"phone_number": {
"phone_number": "+12025550102"
},
"sip_trunk": {
"id": "uuid-of-backup-trunk",
"friendly_name": "Backup US Trunk"
},
"rules": {
"ring_timeout": 25,
"retry": "retry",
"busy": "ai_agent",
"no_answer": "ai_agent",
"unavailable": "hang_up"
}
}
],
"rules": {
"ring_timeout": 30,
"max_retries": 2,
"retry_delay": 3,
"fallback": "ai_agent",
"continue_recording": true
},
"sip_refer": false,
"fromHours": "09:00",
"toHours": "17:00",
"timezone": "America/New_York"
}
]
}
```
### 4.2 Field Reference
#### Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `eventType` | String | Yes | Must be `"forward_number"` |
| `phone_numbers` | Array | Yes | Ordered list of numbers to try |
| `rules` | Object | Yes | Global rules for the transfer |
| `sip_refer` | Boolean | No | Use SIP REFER instead of bridge (default: false) |
| `fromHours` | String | No | Start of business hours, e.g. `"09:00"` |
| `toHours` | String | No | End of business hours, e.g. `"17:00"` |
| `timezone` | String | No | IANA timezone, e.g. `"America/New_York"` |
#### Per-Number Rules (`phone_numbers[n].rules`)
| Field | Type | Description | Valid Values |
|---|---|---|---|
| `ring_timeout` | Integer | Seconds to wait for answer on this number | e.g. `30` |
| `retry` | String | What to do on network retry | `"retry"`, `"next_number"`, `"ai_agent"`, `"hang_up"` |
| `busy` | String | What to do on BUSY | same as above |
| `no_answer` | String | What to do on NOANSWER | same as above |
| `unavailable` | String | What to do on CONGESTION/CHANUNAVAIL | same as above (also `"switch_trunk"`) |
#### Global Rules (`rules`)
| Field | Type | Description |
|---|---|---|
| `ring_timeout` | Integer | Default ring timeout if not set per number |
| `max_retries` | Integer | **Total** number of dials allowed on a single number |
| `retry_delay` | Integer | Milliseconds to wait between retry attempts |
| `fallback` | String | Final fallback: `"ai_agent"` or `"hang_up"` |
| `continue_recording` | Boolean | Whether to record the transferred leg |
### 4.3 Action Values Explained
| Action Value | What Happens |
|---|---|
| `"retry"` | Dial the same number again (up to `max_retries`) |
| `"next_number"` | Move to the next number in the list |
| `"switch_trunk"` | Keep the same number but use the backup SIP trunk |
| `"ai_agent"` | Stop transfer, return caller to AI agent |
| `"hang_up"` | End the call entirely |
---
## 5. Complete Call Flow — Step by Step
### Phase 1: Normal Call (Before Transfer)
```
1. Caller dials in → PBX receives call
2. PBX starts conversation → assigns conversation_id
3. AI agent handles the call (greets, listens, responds)
4. AI decides: "I need to transfer this caller to a human agent"
5. AI signals the PBX to begin transfer
```
---
### Phase 2: Stage A — Get Transfer Metadata
```
6. PBX calls:
GET /kalimera/Transfers/GetTransferMetadata/{conversation_id}
7. MyBuddy API:
a. Loads the conversation → finds agent_id
b. Loads agent configuration → finds "forward_number" event node
c. Checks time-of-day schedule (if configured)
→ If outside hours: returns fallback action immediately
d. Picks first phone number from the list
e. Resolves SIP trunk for that number
f. Creates KalimeraTransferSession row in DB (or resets existing)
g. Returns metadata to PBX
8. API Response (Stage A):
{
"transfer_number": "+12025550101",
"trunk_id": "uuid-of-primary-trunk",
"ring_timeout": 30,
"max_retries": 2,
"retry_delay": 3000,
"fallback_action": "resume_ai",
"sipRefer": false,
"continue_recording": true
}
9. PBX stores this metadata and starts dialing +12025550101
```
---
### Phase 3: Stage B — Report Transfer Outcome
```
10. Transfer attempt completes (answered, busy, no answer, etc.)
11. PBX calls:
POST /kalimera/Transfers/ReportTransferOutcome
{
"conversationId": "abc123",
"attempt": 1,
"dialedNumber": "+12025550101",
"dialedTrunk": "uuid-of-primary-trunk",
"dialstatus": "NOANSWER",
"hangupcauseQ850": 19,
"techCause": "NO_ANSWER",
"hangupSource": "remote",
"timestamp": "2026-03-19T10:30:00Z"
}
12. MyBuddy API:
a. Checks idempotency: (conversationId="abc123", attempt=1)
→ If already processed: return same cached response
b. Routes on dialstatus:
- ANSWER → success, session closed
- CANCEL → caller hung up, session closed
- INVALIDARGS → bad config, hangup
- BUSY / NOANSWER / CONGESTION / CHANUNAVAIL → decision engine
c. Looks up per-number rule for "no_answer" → "next_number"
d. Checks: are more numbers available?
→ Yes: move to next number
e. Saves KalimeraTransferOutcome row (idempotent record)
f. Updates KalimeraTransferSession state
g. Returns next action
13. API Response (Stage B):
{
"action": "dial_next",
"nextNumber": "+12025550102",
"nextTrunk": "uuid-of-backup-trunk",
"timeoutSec": 25,
"waitMs": 3000,
"nextConversationId": null
}
14. PBX waits 3 seconds, then dials +12025550102
```
---
### Phase 4: All Transfers Failed → Resume AI
```
15. Second number also fails (NOANSWER), per-number rule = "ai_agent"
16. PBX calls ReportTransferOutcome again (attempt=2)
17. MyBuddy API decides action = "resume_ai":
a. Saves failure context to conversation_metadata:
{
"transfer_to_human_agent_failed": true,
"transfer_fail_reason": "NOANSWER",
"transfer_fail_attempts": 2,
"transfer_fail_last_number": "+12025550102"
}
b. Creates a NEW conversation (resume leg):
- Same caller, same agent
- call_type = "resume_ai"
- root_conversation_id = original conversation_id
c. Returns:
{
"action": "resume_ai",
"nextConversationId": "xyz789",
"waitMs": 0
}
18. PBX reconnects caller's audio to orchestrator using new conversation ID
```
---
### Phase 5: AI Resumes with Context
```
19. Orchestrator starts new session with conversation_id = "xyz789"
20. Orchestrator calls:
GET /kalimera/Transfers/ResumeContext/{original_conv_id}
Response:
{
"isFailedTransfer": true,
"resumeReason": "NOANSWER",
"totalAttempts": 2,
"lastDialedNumber": "+12025550102",
"lastAction": "resume_ai"
}
21. Orchestrator calls:
GET /kalimera/ai-agent/{xyz789}/configuration
Response includes extra fields:
{
"TransferToHumanAgentFailed": true,
"TransferFailReason": "NOANSWER",
"TransferFailAttempts": 2,
"PreviousConversation": [
{ "role": "user", "content": "I need help with my account" },
{ "role": "assistant", "content": "Let me transfer you..." }
]
}
22. AI agent resumes with full context — can say:
"I'm sorry, I wasn't able to reach a human agent. I tried 2 numbers
but nobody answered. Let me help you directly instead..."
```
---
## 6. API Reference
### 6.1 Get Transfer Metadata (Stage A)
```
GET /kalimera/Transfers/GetTransferMetadata/{conversation_id}
Authorization: Required
```
**Path Parameter:**
- `conversation_id` — The active call's conversation ID
**Success Response (200):**
```json
{
"transfer_number": "+12025550101",
"trunk_id": "uuid-string",
"ring_timeout": 30,
"max_retries": 2,
"retry_delay": 3000,
"fallback_action": "resume_ai",
"sipRefer": false,
"continue_recording": true
}
```
**Error Responses:**
- `404` — Conversation or agent not found
- `422` — No `forward_number` event configured in agent
- `503` — Outside business hours (returns fallback action)
---
### 6.2 Report Transfer Outcome (Stage B)
```
POST /kalimera/Transfers/ReportTransferOutcome
Authorization: Required
Content-Type: application/json
```
**Request Body (all camelCase):**
```json
{
"conversationId": "string",
"attempt": 1,
"dialedNumber": "+12025550101",
"dialedTrunk": "uuid-string",
"dialstatus": "NOANSWER",
"hangupcauseQ850": 19,
"techCause": "NO_ANSWER",
"hangupSource": "remote",
"timestamp": "2026-03-19T10:30:00Z"
}
```
**Field Details:**
| Field | Type | Required | Description |
|---|---|---|---|
| conversationId | String | Yes | Active call ID |
| attempt | Integer | Yes | Attempt number (starts at 1) |
| dialedNumber | String | Yes | Number that was dialed |
| dialedTrunk | String | No | SIP trunk UUID used |
| dialstatus | String | Yes | PBX result code |
| hangupcauseQ850 | Integer | No | Q.850 code |
| techCause | String | No | SIP cause string |
| hangupSource | String | No | Who hung up |
| timestamp | String | No | ISO 8601 timestamp |
**dialstatus Values:**
| Value | Meaning |
|---|---|
| `ANSWER` | Human answered — transfer succeeded |
| `BUSY` | Line was busy |
| `NOANSWER` | No answer within timeout |
| `CONGESTION` | Network/trunk congestion |
| `CHANUNAVAIL` | SIP channel unavailable |
| `CANCEL` | Caller hung up during transfer |
| `INVALIDARGS` | Bad configuration passed |
**Success Response (200):**
```json
{
"action": "dial_next",
"nextNumber": "+12025550102",
"nextTrunk": "uuid-string",
"timeoutSec": 25,
"waitMs": 3000,
"nextConversationId": null
}
```
**action Values in Response:**
| Action | Meaning |
|---|---|
| `success` | Transfer answered — all done |
| `hangup` | End the call |
| `retry_same` | Dial same number again |
| `dial_next` | Dial next number in list |
| `switch_trunk` | Redial same number via backup trunk |
| `resume_ai` | Return caller to AI agent |
---
### 6.3 Get Resume Context
```
GET /kalimera/Transfers/ResumeContext/{conversation_id}
Authorization: Required
```
**Note:** `conversation_id` here is the **original** call's ID, not the new resume leg.
**Success Response (200):**
```json
{
"isFailedTransfer": true,
"resumeReason": "NOANSWER",
"totalAttempts": 2,
"lastDialedNumber": "+12025550102",
"lastAction": "resume_ai"
}
```
---
### 6.4 Get Transfer History
```
GET /kalimera/Transfers/History/{conversation_id}
Authorization: Required
```
Returns all transfer attempt records for a conversation, ordered by attempt number.
**Success Response (200):**
```json
[
{
"attempt": 1,
"dialedNumber": "+12025550101",
"dialstatus": "NOANSWER",
"decisionAction": "dial_next",
"createdAt": "2026-03-19T10:30:00Z"
},
{
"attempt": 2,
"dialedNumber": "+12025550102",
"dialstatus": "NOANSWER",
"decisionAction": "resume_ai",
"createdAt": "2026-03-19T10:30:35Z"
}
]
```
---
### 6.5 Get Active Transfer Session
```
GET /kalimera/Transfers/ActiveSession/{conversation_id}
Authorization: Required
```
Returns the current live session state — useful for monitoring dashboards.
**Success Response (200):**
```json
{
"conversationId": "abc123",
"isActive": true,
"currentNumberIndex": 1,
"currentRetryCount": 0,
"totalAttempts": 2,
"trunkSwitched": false,
"finalStatus": null
}
```
---
## 7. Scenarios & Examples
---
### Scenario 1: Simple Success — Human Answers First Try
**Setup:** One phone number configured, no retries needed.
```
AI → "Transferring you now"
PBX → GET /GetTransferMetadata/abc123
API → { transfer_number: "+15551234", ring_timeout: 30, ... }
PBX → Dials +15551234
Human answers ✓
PBX → POST /ReportTransferOutcome { dialstatus: "ANSWER", attempt: 1 }
API → { action: "success" }
Session closed. Call live between caller ↔ human.
```
---
### Scenario 2: Number Busy → Try Next Number
**Setup:** Two numbers, `busy` rule = `"next_number"`.
```
PBX → GET /GetTransferMetadata/abc123
API → { transfer_number: "+15551111", ... }
Attempt 1: Dials +15551111
PBX → POST /ReportTransferOutcome { dialstatus: "BUSY", attempt: 1 }
API → { action: "dial_next", nextNumber: "+15552222", waitMs: 3000 }
PBX waits 3s, dials +15552222
Attempt 2: Human answers ✓
PBX → POST /ReportTransferOutcome { dialstatus: "ANSWER", attempt: 2 }
API → { action: "success" }
```
---
### Scenario 3: Retry Same Number (max_retries = 3)
**Setup:** One number, `no_answer` rule = `"retry"`, max_retries = 3.
```
Attempt 1: Dials +15551111 → NOANSWER
API → { action: "retry_same", nextNumber: "+15551111", waitMs: 5000 }
Attempt 2: Dials +15551111 → NOANSWER
API → { action: "retry_same", nextNumber: "+15551111", waitMs: 5000 }
Attempt 3: Dials +15551111 → NOANSWER
(max_retries=3 reached, no more retries allowed)
No more numbers in list
API → { action: "resume_ai", nextConversationId: "xyz789" }
PBX reconnects caller to AI agent with new conversation ID.
```
---
### Scenario 4: All Numbers Fail → AI Resumes
**Setup:** Three numbers, all fail, fallback = `"ai_agent"`.
```
Attempt 1: +15551111 → NOANSWER → dial_next
Attempt 2: +15552222 → BUSY → dial_next
Attempt 3: +15553333 → NOANSWER → (last number, fallback=ai_agent)
API → {
action: "resume_ai",
nextConversationId: "resume-leg-id-789"
}
New conversation created:
call_type = "resume_ai"
root_conversation_id = "abc123" (original call)
Orchestrator starts new session with "resume-leg-id-789"
Fetches /ResumeContext/abc123 → learns transfer failed
Fetches /ai-agent/resume-leg-id-789/configuration →
TransferToHumanAgentFailed: true
TransferFailReason: "NOANSWER"
TransferFailAttempts: 3
PreviousConversation: [ prior messages... ]
AI says: "I'm sorry, I couldn't reach a representative. All 3 numbers
were unavailable. Can I help you a different way?"
```
---
### Scenario 5: Caller Hangs Up During Transfer
```
Attempt 1: Dials +15551111
Caller hangs up before anyone answers.
PBX → POST /ReportTransferOutcome { dialstatus: "CANCEL", attempt: 1 }
API → { action: "hangup" }
Session closed. Nothing to resume — caller is gone.
```
---
### Scenario 6: Network Congestion → Switch Trunk
**Setup:** Primary trunk congested, backup trunk configured.
```
Attempt 1: Dials +15551111 via Primary-Trunk → CONGESTION
Per-number rule: unavailable = "switch_trunk"
API → { action: "switch_trunk", nextNumber: "+15551111",
nextTrunk: "backup-trunk-uuid", waitMs: 2000 }
Attempt 2: Dials +15551111 via Backup-Trunk → ANSWER ✓
API → { action: "success" }
```
---
### Scenario 7: Outside Business Hours
**Setup:** Business hours 09:00–17:00 EST, call at 20:00 EST.
```
PBX → GET /GetTransferMetadata/abc123
API checks time: 20:00 EST → outside 09:00–17:00 range
API → { action: "resume_ai", nextConversationId: "..." }
(Never dials any number — returns immediately with fallback)
OR if fallback = "hang_up":
API → { action: "hangup" }
```
---
### Scenario 8: SIP REFER Mode
**Setup:** `sip_refer: true` in agent config.
```
PBX → GET /GetTransferMetadata/abc123
API →
sipRefer: true
continue_recording: false ← forced off in REFER mode
trunk_id: <original call's SIP trunk> ← forced, no custom trunk
PBX sends SIP REFER signal to original trunk.
Transfer is handled at SIP layer, not via bridge.
Recording is disabled for the transferred leg.
```
---
### Scenario 9: Idempotent Retry (PBX Sends Duplicate)
**Setup:** PBX accidentally sends the same outcome report twice.
```
POST /ReportTransferOutcome { conversationId: "abc123", attempt: 2, dialstatus: "BUSY" }
→ API processes, records decision: { action: "dial_next" }
POST /ReportTransferOutcome { conversationId: "abc123", attempt: 2, dialstatus: "BUSY" }
→ API finds existing KalimeraTransferOutcome for (abc123, 2)
→ Returns exact same response without re-processing
→ Session state not modified
Safe to retry at any time.
```
---
### Scenario 10: Per-Number Override vs. Global Rule
**Setup:** Global fallback = `"ai_agent"`, but last number's `busy` rule = `"hang_up"`.
```
All numbers exhausted except the last one.
Last number: +15553333 → BUSY
Per-number rule for "busy" = "hang_up"
API → { action: "hangup" }
← Per-number rule wins over global fallback
```
---
## 8. Decision Logic — How the System Picks the Next Action
### 8.1 Dialstatus Priority Tree
```
dialstatus received
├── ANSWER → action = "success" (session closed)
├── CANCEL → action = "hangup" (caller gone, session closed)
├── INVALIDARGS → action = "hangup" (bad config)
└── BUSY / NOANSWER / CONGESTION / CHANUNAVAIL
├── Look up per-number rule:
│ BUSY → phone_number.rules.busy
│ NOANSWER → phone_number.rules.no_answer
│ CONGESTION → phone_number.rules.unavailable
│ CHANUNAVAIL→ phone_number.rules.unavailable
│
├── Rule = "retry"
│ ├── current_retry_count < (max_retries - 1)?
│ │ → action = "retry_same"
│ └── else → try next number (or fallback)
│
├── Rule = "next_number"
│ → Move to next number in list
│ ├── Next number exists?
│ │ → action = "dial_next"
│ └── else → fallback action
│
├── Rule = "switch_trunk"
│ ├── Backup trunk exists AND not yet switched?
│ │ → action = "switch_trunk"
│ └── else → try next number (or fallback)
│
├── Rule = "ai_agent"
│ → action = "resume_ai"
│
└── Rule = "hang_up"
→ action = "hangup"
```
### 8.2 max_retries Semantics
`max_retries = N` means **N total dials** on a number (not N retries after the first).
| max_retries | Total dials allowed |
|---|---|
| 1 | Dial once, no retry |
| 2 | Dial once + 1 retry |
| 3 | Dial once + 2 retries |
---
## 9. SIP REFER Mode
SIP REFER is an alternative transfer mechanism at the SIP protocol level.
### Normal Transfer (Bridge Mode)
```
Caller ──────────────── PBX ──────────────── Human Agent
Bridge (PBX controls both legs)
```
### SIP REFER Mode
```
Caller ────── Original SIP Trunk ──── Human Agent
(PBX signals trunk via REFER, then steps out)
```
### SIP REFER Constraints
| Constraint | Reason |
|---|---|
| Must use original call's SIP trunk | REFER is sent to the originating trunk |
| No custom trunk selection | Custom trunk can't receive REFER |
| Recording disabled | PBX exits the media path |
| No retry logic | REFER is one-shot — PBX cannot monitor outcome |
### When to Use SIP REFER
- When the SIP provider supports REFER and handles the transfer natively
- When you want to reduce PBX load (PBX exits media path)
- When billing should transfer to the originating trunk provider
### When NOT to Use SIP REFER
- When you need retry logic (REFER is fire-and-forget)
- When you need recording of the transferred call
- When the SIP provider does not support REFER
---
## 10. Multi-Leg Call (Resume AI)
When a transfer fails and the action is `resume_ai`, the system creates a new conversation "leg" that is linked to the original call.
### Conversation Leg Structure
```
Original Call
└── conversation_id: "abc123"
call_type: "inbound"
root_conversation_id: null (IS the root)
Resume Leg (created when transfer fails)
└── conversation_id: "xyz789"
call_type: "resume_ai"
root_conversation_id: "abc123" ← links back to original
(If transfer fails again from resume leg, another leg is created)
Resume Leg 2
└── conversation_id: "lmn456"
call_type: "resume_ai"
root_conversation_id: "abc123" ← always points to original root
```
### What Gets Copied to Resume Leg
- tenant_id
- agent_id
- campaign_id
- dialplan_id
- customer_id
- voice_id
- from_number (caller's number)
- to_number (DID)
- sip_id (SIP trunk)
- language
### Voice History Continuity
The AI agent's voice history (conversation transcript) is stored in Azure Blob Storage:
```
kalimera/{tenant_id}/voice/{root_conversation_id}.json
```
When the orchestrator loads the resume leg's configuration, the `PreviousConversation` field returns the **original call's** voice history — so the AI remembers the entire conversation.
---
## 11. Idempotency
The `kalimera_transfer_outcomes` table has a unique constraint on `(conversation_id, attempt)`.
**Why this matters:**
- PBX/AGI scripts may retry HTTP calls on timeout
- Network failures can cause duplicate reports
- Same outcome always returns same decision — session state is never double-applied
**How it works:**
1. API receives outcome report for (conv_id, attempt)
2. Checks DB for existing record
3. If found → return cached response immediately
4. If not found → process, save, return new response
---
## 12. Time-of-Day Scheduling
Configure business hours in the agent's `forward_number` event:
```json
{
"fromHours": "09:00",
"toHours": "17:00",
"timezone": "America/New_York"
}
```
**Behavior:**
- Both `fromHours` and `toHours` must be present to activate scheduling
- If either is missing → treat as 24/7 available
- Check is timezone-aware (converts current UTC time to specified timezone)
- If outside hours → immediately returns fallback action (no dial attempts)
**Examples:**
| Config | Call Time (local) | Result |
|---|---|---|
| 09:00–17:00 EST | 14:00 EST | Transfer proceeds |
| 09:00–17:00 EST | 20:00 EST | Returns fallback |
| 09:00–17:00 EST | 09:00 EST | Transfer proceeds (inclusive) |
| No hours configured | Any time | Transfer proceeds |
---
## 13. Trunk Switching
Each phone number entry can specify its own SIP trunk. The system uses the first number's trunk as **primary** and the second number's trunk as **backup** (if different).
### Automatic Trunk Switch
When `CONGESTION` or `CHANUNAVAIL` occurs and:
- The per-number rule is `"switch_trunk"`, AND
- A backup trunk exists, AND
- The trunk has not been switched yet in this session
→ The system retries the **same phone number** on the **backup trunk**.
**Note:** Trunk switch happens at most **once per session** (`trunk_switched` flag in session).
### Example Trunk Resolution
```json
"phone_numbers": [
{
"phone_number": { "phone_number": "+15551111" },
"sip_trunk": { "id": "trunk-A" } ← primary trunk
},
{
"phone_number": { "phone_number": "+15552222" },
"sip_trunk": { "id": "trunk-B" } ← backup trunk (different ID)
}
]
```
If CONGESTION on +15551111 via trunk-A and rule=switch_trunk:
→ Retry +15551111 via trunk-B (backup)
---
## 14. Recording Control
The `continue_recording` flag in global rules controls whether the transferred call leg is recorded.
| Scenario | Recording |
|---|---|
| `continue_recording: true` | Recorded |
| `continue_recording: false` | Not recorded |
| `sip_refer: true` (any setting) | Always disabled (PBX not in media path) |
---
## 15. Monitoring & Debugging
### Check Current Transfer Session State
```
GET /kalimera/Transfers/ActiveSession/{conversation_id}
```
Use this to see live state: which number index, retry count, total attempts.
### Check All Transfer Attempts
```
GET /kalimera/Transfers/History/{conversation_id}
```
Returns ordered history of every dial attempt and what decision was made.
### Check Resume Context
```
GET /kalimera/Transfers/ResumeContext/{conversation_id}
```
Call with the **original** conversation ID to see why transfer failed.
### Import/Export Forward Numbers
```
POST /dialer/forward-call/export ← Export configured forward numbers
POST /dialer/forward-call/import ← Import forward numbers from file
```
Both require `tenant_id` in the request.
---
## 16. Quick Reference Card
### For Voice AI / Orchestrator Engineers
| Question | Answer |
|---|---|
| How do I know a call is a resume? | `call_type == "resume_ai"` in conversation |
| How do I get the original conversation? | `root_conversation_id` on the conversation |
| How do I get transfer fail context? | `GET /Transfers/ResumeContext/{original_id}` |
| Where is previous voice history? | Agent config response: `PreviousConversation` field |
| How do I know transfer failed? | `TransferToHumanAgentFailed: true` in agent config |
| How many times was transfer tried? | `TransferFailAttempts` in agent config |
| What was the reason? | `TransferFailReason` (the dialstatus value) |
---
### For PBX / AGI Engineers
| Question | Answer |
|---|---|
| When do I call Stage A? | When AI signals transfer intent |
| When do I call Stage B? | After every dial attempt (success or fail) |
| What if I get a duplicate response? | Safe — idempotent, same response guaranteed |
| What if `action = "resume_ai"`? | Use `nextConversationId` to reconnect caller to AI |
| What if `action = "dial_next"`? | Use `nextNumber` + `nextTrunk`, wait `waitMs` ms |
| What if `action = "switch_trunk"`? | Dial same number via `nextTrunk`, wait `waitMs` ms |
| What if `sipRefer: true`? | Use SIP REFER signal, disable recording, no retry |
| What if outside business hours? | API returns fallback immediately (no numbers given) |
---
### dialstatus → Likely Next Action (Default Config)
| dialstatus | Default Per-Number Rule | Typical Next Action |
|---|---|---|
| ANSWER | — | success |
| BUSY | `busy` | next_number or retry_same |
| NOANSWER | `no_answer` | next_number or retry_same |
| CONGESTION | `unavailable` | switch_trunk or dial_next |
| CHANUNAVAIL | `unavailable` | switch_trunk or dial_next |
| CANCEL | — | hangup (caller gone) |
| INVALIDARGS | — | hangup (config error) |
---
### Service & File Locations
| Component | File Path |
|---|---|
| Transfer service (core logic) | `api/services/kalimera/kalimera_transfer_call_service.py` |
| API endpoints | `api/controllers/console/kalimera/kalimera.py` (lines 1106–1285) |
| Call log / resume leg | `api/services/kalimera/kalimera_call_log_service.py` |
| Agent config service | `api/services/kalimera/kalimera_agent_service.py` |
| DB models | `api/models/kalimera.py` (lines 570–692) |
| DB migration | `api/migrations/versions/2026_03_06_1041-32d78427fc19_*.py` |
| Forward number endpoints | `api/controllers/kalimera/kalimera.py` |