# Telephony Overhaul — Ozonetel CloudAgent Integration

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Fix the end-to-end telephony flow so inbound and outbound calls work reliably with proper agent state management via Ozonetel CloudAgent APIs.

**Architecture:** The sidecar (NestJS BFF) is the single backend for all Ozonetel API calls. The frontend (React) handles SIP signaling via JsSIP and sends disposition + UCID to the sidecar. The sidecar posts disposition to both Ozonetel (to end ACW) and the platform (to create Call records). UCID is the thread that ties the call across all three systems.

**Tech Stack:** NestJS sidecar, React 19 + Jotai, JsSIP, Ozonetel CloudAgent Token Auth APIs

**Ozonetel API Reference:**
- Token: `POST /ca_apis/CAToken/generateToken` — apiKey header + `{userName}` body → JWT (60 min)
- Login: `POST /CAServices/AgentAuthenticationV2/index.php` — HTTP Basic Auth + form body
- Manual Dial: `POST /ca_apis/AgentManualDial` — Bearer token + `{userName, agentID, campaignName, customerNumber, UCID:"true"}`
- Set Disposition: `POST /ca_apis/DispositionAPIV2` — Bearer token + `{userName, agentID, did, ucid, action:"Set", disposition, autoRelease:"true"}`
- Change Agent State: `POST /ca_apis/changeAgentState` — Bearer token + `{userName, agentId, state:"Ready"|"Pause"}`

**Constants:**
- Account: `global_healthx`
- Agent: `global` / `Test123$`
- DID: `918041763265`
- SIP Extension: `523590`
- Campaign: `Inbound_918041763265`
- Campaign Dispositions: `General Enquiry` (only one configured currently)

---

## Current Bugs (root causes)

1. **Agent stuck in ACW after call** — We never call Set Disposition API on Ozonetel. CloudAgent doesn't know the call is done, keeps agent in ACW indefinitely (wrapup timer doesn't help because CloudAgent expects a disposition submission).
2. **outboundPending flag poisons inbound calls** — After an outbound call, the flag isn't reliably reset, causing the next inbound SIP INVITE to be auto-answered silently instead of ringing.
3. **No UCID tracking** — The UCID (Unique Call ID) ties the call across Ozonetel, SIP, and our platform. We get it from ManualDial response and SIP X-UCID header but never store it.
4. **Duplicate Call records** — Both the webhook (server-side) and the disposition form (client-side) create Call records in the platform for the same call.
5. **"Ready" badge lies** — Shows SIP registration status, not CloudAgent agent state. Agent can be SIP-registered but in ACW.

## File Map

### Sidecar (helix-engage-server)
| File | Responsibility | Action |
|------|---------------|--------|
| `src/ozonetel/ozonetel-agent.service.ts` | All Ozonetel API calls (token, login, dial, disposition, state) | Modify: add `setDisposition()` |
| `src/ozonetel/ozonetel-agent.controller.ts` | REST endpoints for frontend | Modify: add `POST /api/ozonetel/dispose`, update `POST /api/ozonetel/dial` to return UCID |
| `src/worklist/missed-call-webhook.controller.ts` | Ozonetel webhook → platform Call records | Keep as-is (handles missed/unanswered calls the frontend never sees) |

### Frontend (helix-engage)
| File | Responsibility | Action |
|------|---------------|--------|
| `src/state/sip-state.ts` | Jotai atoms for call state | Modify: add `sipCallUcidAtom` |
| `src/state/sip-manager.ts` | Singleton SIP client manager + outbound flags | Modify: extract UCID from SIP headers, store in atom, clean up outbound flags |
| `src/lib/sip-client.ts` | JsSIP wrapper | Modify: extract and expose UCID from SIP INVITE `X-UCID` header |
| `src/components/call-desk/active-call-card.tsx` | Call UI + disposition flow | Modify: pass UCID to sidecar disposition endpoint, remove duplicate Call record creation |
| `src/components/call-desk/click-to-call-button.tsx` | Outbound call trigger | Modify: store UCID from dial response |
| `src/pages/call-desk.tsx` | Call desk page layout | Modify: pass UCID to ActiveCallCard |
| `src/providers/sip-provider.tsx` | SIP lifecycle + Jotai bridge | Modify: expose UCID in `useSip()` hook |

---

## Task 1: Add UCID atom and extraction

Track the Ozonetel UCID (Unique Call ID) for every call so we can submit disposition against it.

**Files:**
- Modify: `helix-engage/src/state/sip-state.ts`
- Modify: `helix-engage/src/lib/sip-client.ts`
- Modify: `helix-engage/src/state/sip-manager.ts`
- Modify: `helix-engage/src/providers/sip-provider.tsx`

- [ ] **Step 1: Add UCID atom**

In `src/state/sip-state.ts`, add:
```typescript
export const sipCallUcidAtom = atom<string | null>(null);
```

- [ ] **Step 2: Extract UCID from SIP INVITE in sip-client.ts**

In `extractCallerNumber`, also extract and return UCID. Change the method signature and the `newRTCSession` handler to extract `X-UCID` from the SIP request:

```typescript
// In newRTCSession handler, after extracting callerNumber:
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
console.log('[SIP] X-UCID:', ucid);
```

Update the `onCallStateChange` callback type to include ucid:
```typescript
private onCallStateChange: (state: CallState, callerNumber?: string, ucid?: string) => void,
```

Pass ucid in all `onCallStateChange` calls within `newRTCSession`.

- [ ] **Step 3: Store UCID in sip-manager.ts**

Add `setCallUcid` to the `StateUpdater` type. In the callback, store the UCID when a call starts:
```typescript
type StateUpdater = {
    setConnectionStatus: (status: ConnectionStatus) => void;
    setCallState: (state: CallState) => void;
    setCallerNumber: (number: string | null) => void;
    setCallUcid: (ucid: string | null) => void;
};
```

In the callback, for inbound calls set UCID from the SIP header. For outbound calls, UCID comes from the ManualDial API response (stored via a separate setter — see Task 3).

- [ ] **Step 4: Expose UCID in sip-provider.tsx**

Register `setCallUcid` in the state updater. Expose `callUcid` in the `useSip()` hook return.

- [ ] **Step 5: Commit**

```
feat: track UCID from SIP headers for Ozonetel disposition
```

---

## Task 2: Add Set Disposition API to sidecar

The sidecar calls Ozonetel's Set Disposition API to end ACW and simultaneously creates the Call record on our platform.

**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.service.ts`
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`

- [ ] **Step 1: Add `setDisposition()` to service**

```typescript
async setDisposition(params: {
    agentId: string;
    ucid: string;
    disposition: string;
}): Promise<{ status: string; message?: string }> {
    const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
    const did = process.env.OZONETEL_DID ?? '918041763265';

    this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);

    try {
        const token = await this.getToken();
        const response = await axios.post(url, {
            userName: this.accountId,
            agentID: params.agentId,
            did,
            ucid: params.ucid,
            action: 'Set',
            disposition: params.disposition,
            autoRelease: 'true',
        }, {
            headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
        });

        this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`);
        return response.data;
    } catch (error: any) {
        const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
        this.logger.error(`Set disposition failed: ${error.message} ${responseData}`);
        throw error;
    }
}
```

- [ ] **Step 2: Add `POST /api/ozonetel/dispose` endpoint**

This endpoint receives disposition from the frontend and does two things in parallel:
1. Calls Ozonetel Set Disposition (to end ACW)
2. Creates Call record on platform (moves this from frontend to sidecar)

```typescript
@Post('dispose')
async dispose(
    @Body() body: {
        ucid: string;
        disposition: string;
        callerPhone?: string;
        direction?: string;
        durationSec?: number;
        leadId?: string;
        notes?: string;
    },
) {
    if (!body.ucid || !body.disposition) {
        throw new HttpException('ucid and disposition required', 400);
    }

    this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`);

    // Map our disposition to Ozonetel campaign disposition
    const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);

    // Fire both in parallel — Ozonetel disposition + platform Call record
    const [ozResult] = await Promise.allSettled([
        this.ozonetelAgent.setDisposition({
            agentId: this.defaultAgentId,
            ucid: body.ucid,
            disposition: ozonetelDisposition,
        }),
        // Platform call record creation can be added here if needed
        // (currently the webhook already creates it)
    ]);

    if (ozResult.status === 'rejected') {
        this.logger.error(`Ozonetel disposition failed: ${ozResult.reason}`);
    }

    return { status: 'success' };
}

private mapToOzonetelDisposition(disposition: string): string {
    // Campaign only has 'General Enquiry' configured
    // Map all our dispositions to it for now
    const map: Record<string, string> = {
        'APPOINTMENT_BOOKED': 'General Enquiry',
        'FOLLOW_UP_SCHEDULED': 'General Enquiry',
        'INFO_PROVIDED': 'General Enquiry',
        'NO_ANSWER': 'General Enquiry',
        'WRONG_NUMBER': 'General Enquiry',
        'CALLBACK_REQUESTED': 'General Enquiry',
    };
    return map[disposition] ?? 'General Enquiry';
}
```

- [ ] **Step 3: Remove `agent-ready` endpoint**

The `agent-ready` endpoint is no longer needed — `autoRelease: "true"` in Set Disposition handles the agent state transition. Delete the `agentReady()` method from the controller. Keep `changeAgentState()` in the service for future use.

- [ ] **Step 4: Update `dial` endpoint to return UCID**

The ManualDial response already includes `ucid`. Ensure the controller returns it:
```typescript
// Already works — response.data includes { status, ucid }
return result;
```

- [ ] **Step 5: Commit**

```
feat: add Ozonetel Set Disposition API for proper ACW release
```

---

## Task 3: Update frontend disposition flow

The frontend sends disposition + UCID to the sidecar instead of creating Call records directly.

**Files:**
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
- Modify: `helix-engage/src/components/call-desk/click-to-call-button.tsx`

- [ ] **Step 1: Store outbound UCID from dial response**

In `click-to-call-button.tsx`, the dial API already returns `{ ucid }`. Store it in the Jotai atom:

```typescript
const setCallUcid = useSetAtom(sipCallUcidAtom);

// In handleDial, after successful dial:
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
if (result?.ucid) {
    setCallUcid(result.ucid);
}
```

- [ ] **Step 2: Rewrite handleDisposition in active-call-card.tsx**

Replace the current `handleDisposition` which creates Call records via GraphQL with a single call to the sidecar `/api/ozonetel/dispose` endpoint:

```typescript
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
    setSavedDisposition(disposition);

    // Send disposition to sidecar — handles both Ozonetel ACW release + platform Call record
    const ucid = callUcid; // from useSip() hook
    if (ucid) {
        apiClient.post('/api/ozonetel/dispose', {
            ucid,
            disposition,
            callerPhone,
            direction: callDirectionRef.current,
            durationSec: callDuration,
            leadId: lead?.id ?? null,
            notes,
        }).catch((err) => console.warn('Disposition failed:', err));
    }

    // Continue with local UI flow (appointment, follow-up, done)
    if (disposition === 'APPOINTMENT_BOOKED') { ... }
    ...
};
```

- [ ] **Step 3: Update handleReset**

Remove the `agent-ready` call. Reset UCID atom. Keep `setOutboundPending(false)`:

```typescript
const handleReset = () => {
    setPostCallStage(null);
    setSavedDisposition(null);
    setCallState('idle');
    setCallerNumber(null);
    setCallUcid(null);
    setOutboundPending(false);
};
```

- [ ] **Step 4: Commit**

```
feat: send disposition to sidecar with UCID for Ozonetel ACW release
```

---

## Task 4: Fix outboundPending flag lifecycle

The `outboundPending` flag causes inbound calls to be silently auto-answered when it's not properly reset.

**Files:**
- Modify: `helix-engage/src/state/sip-manager.ts`
- Modify: `helix-engage/src/components/call-desk/click-to-call-button.tsx`

- [ ] **Step 1: Clear outboundPending on SIP ended/failed**

In `sip-manager.ts`, reset both outbound flags when a call ends:

```typescript
// Reset outbound flags when call ends
if (state === 'ended' || state === 'failed') {
    outboundActive = false;
    outboundPending = false;
}
```

- [ ] **Step 2: Keep safety timeout but clear on success too**

In `click-to-call-button.tsx`, clear the safety timer when dial succeeds (the SIP INVITE will arrive and handle the flag):

```typescript
try {
    const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
    if (result?.ucid) setCallUcid(result.ucid);
    // Timer auto-clears outboundPending after 30s if SIP never arrives
} catch {
    clearTimeout(safetyTimer);
    ...
}
```

- [ ] **Step 3: Commit**

```
fix: reset outboundPending on call end to prevent inbound poisoning
```

---

## Task 5: Fix inbound caller ID extraction

The `X-CALLERNO` header is `undefined` for outbound-bridge SIP INVITEs (expected — only real inbound calls have it). But for real inbound calls, the current extraction should work. Verify and clean up the debug logging.

**Files:**
- Modify: `helix-engage/src/lib/sip-client.ts`

- [ ] **Step 1: Remove debug console.logs**

Remove the `[SIP] extractCallerNumber` and `[SIP] X-CALLERNO` debug logs added during debugging. Keep only `console.warn` for actual errors.

- [ ] **Step 2: Commit**

```
chore: remove SIP caller ID debug logging
```

---

## Task 6: Deploy and verify

- [ ] **Step 1: Type check both projects**

```bash
cd helix-engage-server && npx tsc --noEmit
cd helix-engage && npx tsc --noEmit
```

- [ ] **Step 2: Build and deploy sidecar**

```bash
cd helix-engage-server
npm run build
# tar + scp + docker cp + restart
```

- [ ] **Step 3: Build and deploy frontend**

```bash
cd helix-engage
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
VITE_SIP_PASSWORD=523590 \
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
npm run build
# tar + scp to /opt/fortytwo/helix-engage-frontend
```

- [ ] **Step 4: Test outbound flow**

1. Login as rekha.cc@globalhospital.com
2. Click Call on a lead
3. SIP should auto-answer (outbound bridge)
4. Talk, then End → Disposition form
5. Select disposition → "Back to Worklist"
6. Check sidecar logs: `Set disposition response: {"status":"Success"}`
7. Immediately place another outbound call — should work without waiting

- [ ] **Step 5: Test inbound flow**

1. After "Back to Worklist" from above
2. Call the DID from a phone
3. Browser should ring (NOT auto-answer)
4. Answer → talk → End → Disposition → "Back to Worklist"
5. Check sidecar logs: disposition submitted, ACW released
6. Call again — should ring within seconds

- [ ] **Step 6: Test inbound-only flow (no prior outbound)**

1. Hard refresh → login
2. Call the DID
3. Should ring, answer, disposition, back to worklist
4. Call again — should ring

---

## Future: Campaign disposition configuration

Currently all our UI dispositions map to "General Enquiry" because that's the only disposition configured in the Ozonetel campaign. To enable proper mapping:

1. Add dispositions in CloudAgent admin: Campaigns → Inbound_918041763265 → General Info → Dispositions
2. Add: `Appointment Booked`, `Follow Up`, `Info Provided`, `Not Interested`, `Wrong Number`, `No Answer`
3. Update the `mapToOzonetelDisposition()` in the controller to map 1:1

## Future: Agent state badge

The "Ready" badge currently shows SIP registration status. To show actual CloudAgent agent state, periodically poll the Agent State Summary API or listen for state changes. Low priority — the disposition flow should keep the agent in Ready state now.
