---
# System prepended metadata

title: 2-Way call transfer from PBX to Back-end

---

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.
