We need to implement the **Transfer Outcome API** (Stage A and Stage B) on the backend side, using the agent configuration we already have stored.
Here's what Weneed to build:
## **1\. GetTransferMetadata Endpoint (Stage A)**
When the PBX calls `GET /api/Transfers/GetTransferMetadata/{conversationId}` after the AI agent decides to transfer, our backend needs to:
* Look up the conversation by `conversation_id` to find the associated agent
* Read the agent's config (the JSON you showed) to find the `forward_number` event type
* Check if the current time falls within `fromHours`/`toHours` in the specified `timezone`(As per today meeting this is not need to check from my side).
* Return the **first phone number** from `phone_numbers` array, along with the rules (ring\_timeout, max\_retries, etc.)
* Store a transfer session (which number index we're on, attempt count) — likely in Redis since this is real-time
The response would map our config like this: `transferNumber` → first phone\_number, `transferTrunk` → its sip\_trunk, `timeoutSec` → `rules.ring_timeout`, `maxAttempts` → `rules.max_retries`, `fallbackAction` → `rules.fallback` (map `ai_agent` → `resume_ai`, `hang_up` → `hangup`).
## **2\. ReportTransferOutcome Endpoint (Stage B)**
When the PBX reports back with a `dialstatus` (BUSY, NOANSWER, CHANUNAVAIL, etc.), our backend needs to:
* Look up the transfer session state (current phone number index, attempt count)
* Map the `dialstatus` to the per-number rules from your config. For example, if calling phone\_number\[0\] and dialstatus is `BUSY`, check `phone_numbers[0].rules.busy` — which says `"retry"`, so you return `retry_same`. If it says `"ai_agent"`, return `resume_ai`. If `"hang_up"`, return `hangup`.
* If the rule says `retry` and you've exceeded `max_retries`, move to the **next phone number** in the list (`dial_next`)
* If all numbers are exhausted, use the top-level `rules.fallback`
* **Store the decision** in a `transfer_outcomes` table for idempotency — same `(conversation_id, attempt)` always returns the same response
## **3\. What We Need to Create**
**Database table:**
```sql
class TransferOutcome(db.Model):
"""
Stores each transfer attempt decision for idempotency.
Primary Key: (conversation_id, attempt) ensures same input → same output.
"""
__tablename__ = "transfer_outcomes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(String(64), nullable=False, index=True)
tenant_id = Column(String(64), nullable=False, index=True)
attempt = Column(Integer, nullable=False)
# What the PBX reported
dialed_number = Column(String(32), nullable=True)
dialed_trunk = Column(String(64), nullable=True)
dialstatus = Column(String(20), nullable=True) # BUSY, NOANSWER, CHANUNAVAIL, ANSWER, CANCEL, CONGESTION
hangupcause_q850 = Column(Integer, nullable=True)
tech_cause = Column(String(128), nullable=True)
hangup_source = Column(String(128), nullable=True)
# What the backend decided
decision_action = Column(String(20), nullable=True) # hangup, retry_same, dial_next, switch_trunk, resume_ai
decision_number = Column(String(32), nullable=True)
decision_trunk = Column(String(64), nullable=True)
decision_timeout_sec = Column(Integer, nullable=True)
decision_wait_ms = Column(Integer, nullable=True)
decision_message = Column(String(256), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
Index("idx_transfer_idempotency", "conversation_id", "attempt", unique=True),
)
```
```sql
class TransferSession(db.Model):
"""
Tracks the active transfer session state for a conversation.
This replaces/supplements Redis for persistent state tracking.
"""
__tablename__ = "transfer_sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(String(64), nullable=False, unique=True, index=True)
tenant_id = Column(String(64), nullable=False, index=True)
agent_id = Column(String(64), nullable=True)
# Transfer policy from agent config
event_type = Column(String(32), nullable=True) # forward_number
ring_timeout = Column(Integer, default=30)
max_retries = Column(Integer, default=3)
retry_delay = Column(Integer, default=3) # seconds
fallback_action = Column(String(20), default="hangup") # hangup or resume_ai
# Phone numbers list (stored as JSON)
phone_numbers = Column(JSON, nullable=True)
# Current state
current_number_index = Column(Integer, default=0)
current_retry_count = Column(Integer, default=0)
total_attempts = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
final_status = Column(String(20), nullable=True) # success, exhausted, cancelled
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
```
**Redis state** for active transfers (keyed by conversation\_id):
```json
{
"current_number_index": 0,
"retry_count": 0,
"agent_config": { ... },
"fallback_action": "resume_ai"
}
```
**Service layer** — a new `TransferService` (or add to `KalimeraCallLogService`) with methods like `get_transfer_metadata(conversation_id)` and `report_transfer_outcome(conversation_id, attempt, dialstatus, ...)` that contain the decision logic.
**Two new routes** in our API — either as new actions in our existing `KalimeraCallConversation.post()` or as a separate resource class. Given the spec uses different HTTP methods and paths, a separate resource makes more sense.
## **4\. Decision Logic (Core of Stage B)**
```
1. Check idempotency table — if (conversation_id, attempt) exists, return stored decision
2. Get transfer state from Redis
3. Map dialstatus → per-number rule (busy/no_answer/unavailable)
4. If rule == "retry" and retry_count < max_retries → retry_same (with retry_delay as waitMs)
5. If rule == "retry" but retries exhausted → move to next number (dial_next)
6. If rule == "ai_agent" → resume_ai
7. If rule == "hang_up" → hangup
8. If no more numbers → use top-level fallback
9. Store decision in DB, update Redis state, return response
```
## **Why 2 Tables?**
**`transfer_sessions`** \= "Where are we RIGHT NOW in the transfer process?"
It tracks the **live state**: which phone number are we currently trying, how many retries have we done on this number, what's the fallback if everything fails. This gets **updated** on every attempt.
**`transfer_outcomes`** \= "What happened on attempt \#1, \#2, \#3...?"
It stores **each individual attempt and the decision we made**. This is the idempotency table — if the PBX sends the same request twice (network glitch), we look here and return the exact same answer.
## **The Full Flow (Step by Step)**
Let me walk through a real scenario using your agent config:
```
Phone numbers: ["3456" on Sip Test1111, "7890" on Sip Test1111]
Rules: ring_timeout=25, max_retries=2, retry_delay=3, fallback=ai_agent
Phone "3456" rules: no_answer→ai_agent, busy→retry, unavailable→retry
Phone "7890" rules: no_answer→retry, busy→ai_agent, unavailable→hang_up
```
### **Step 1: AI Agent decides to transfer**
The AI agent on the call decides "I need to transfer this call to a human." It closes the AudioSocket connection to the PBX.
### **Step 2: PBX calls Stage A**
```
GET /api/Transfers/GetTransferMetadata/conv-123
```
**What the code does:**
1. Finds conversation `conv-123` in the database
2. Gets the agent linked to this conversation
3. Reads the agent's config JSON (As above Given)
4. Finds the `forward_number` event type
5. Checks: is current time between `01:02` and `04:04` in `America/Vancouver`? If no → returns `shouldHangup: true`
6. If yes → creates a `TransferSession` row:
```
conversation_id: conv-123current_number_index: 0 (pointing to "3456")current_retry_count: 0max_retries: 2phone_numbers: [the full array from config]
```
7. Returns to PBX:
```json
{ "shouldHangup": false, "transferNumber": "3456", "transferTrunk": "Sip Test1111", "timeoutSec": 25, "maxAttempts": 2, "fallbackAction": "resume_ai"}
```
### **Step 3: PBX dials "3456", it's BUSY**
PBX calls Stage B:
```
POST /api/Transfers/report-outcome
{ "conversation_id": "conv-123", "attempt": 1, "dialstatus": "BUSY" }
```
**What the code does:**
1. Checks idempotency: any existing row for `(conv-123, attempt=1)`? No → continue
2. Loads the `TransferSession`: current\_number\_index=0 (phone "3456")
3. Maps `BUSY` → rule key `"busy"`
4. Looks at phone "3456"'s rules: `busy → "retry"`
5. Checks: `current_retry_count (0) < max_retries (2)`? Yes → **retry\_same**
6. Updates session: `current_retry_count = 1`
7. Stores in `transfer_outcomes`: `(conv-123, attempt=1) → retry_same`
8. Returns:
```json
{ "action": "retry_same", "waitMs": 3000, "timeoutSec": 25, "message": "BUSY — retrying same number (attempt 1/2)"}
```
### **Step 4: PBX dials "3456" again, still BUSY**
```
POST: { "conversation_id": "conv-123", "attempt": 2, "dialstatus": "BUSY" }
```
**What happens:**
1. Idempotency check: no row for `(conv-123, attempt=2)` → continue
2. Session: current\_number\_index=0, current\_retry\_count=1
3. `BUSY` → `busy` → phone "3456" says `"retry"`
4. Check: `current_retry_count (1) < max_retries (2)`? Yes → **retry\_same**
5. Updates: `current_retry_count = 2`
6. Returns: `retry_same` with waitMs=3000
### **Step 5: PBX dials "3456" a third time, BUSY again**
```
POST: { "conversation_id": "conv-123", "attempt": 3, "dialstatus": "BUSY" }
```
**What happens:**
1. Session: current\_retry\_count=2
2. `BUSY` → `busy` → "retry"
3. Check: `current_retry_count (2) < max_retries (2)`? **No, retries exhausted\!**
4. Code calls `_try_next_number()` → next\_idx \= 1, which is phone "7890"
5. Updates session: `current_number_index = 1`, `current_retry_count = 0`
6. Returns:
```json
{ "action": "dial_next", "nextNumber": "7890", "nextTrunk": "Sip Test1111", "timeoutSec": 25, "message": "BUSY — trying next number (7890)"}
```
### **Step 6: PBX dials "7890", it's BUSY**
```
POST: { "conversation_id": "conv-123", "attempt": 4, "dialstatus": "BUSY" }
```
**What happens:**
1. Session: current\_number\_index=1 (phone "7890")
2. `BUSY` → `busy` → phone "7890" says `"ai_agent"`
3. This maps directly to **resume\_ai** (no retries, the rule says go to AI)
4. Closes session: `is_active = False, final_status = "exhausted"`
5. Returns:
```json
{ "action": "resume_ai", "message": "BUSY — returning to AI agent"}
```
The PBX reconnects AudioSocket, and the AI says something like "They're unavailable right now, would you like to leave a message?"
### **Idempotency Example**
Now imagine on Step 3, the PBX had a network timeout and retries the same request:
```
POST: { "conversation_id": "conv-123", "attempt": 1, "dialstatus": "BUSY" }
```
The code hits the idempotency check first — finds the existing `transfer_outcomes` row for `(conv-123, attempt=1)` — and returns the **exact same response** without touching the session state. This prevents the retry count from being incorrectly incremented.