# CC Agent Features — Phase 1 Implementation Plan

> **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:** Add call transfer, recording pause, and missed call queue to the CC agent's call desk — the three most impactful features for daily workflow.

**Architecture:** Three new service methods in the NestJS sidecar (callControl, pauseRecording, getAbandonCalls), exposed via REST endpoints. Frontend adds Transfer and Pause Recording buttons to the active call card, and a missed call queue that pulls from Ozonetel instead of our webhook-created records.

**Tech Stack:** NestJS sidecar (Ozonetel Token Auth APIs), React 19 + Jotai + Untitled UI

**Ozonetel API endpoints used:**
- Call Control: `POST /ca_apis/CallControl_V4` — Token auth — CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL
- Recording: `GET /CAServices/Call/Record.php` — apiKey in query string — pause/unPause
- Abandon Calls: `GET /ca_apis/abandonCalls` — Token auth — missed calls list

---

## File Map

### Sidecar (helix-engage-server)
| File | Action |
|------|--------|
| `src/ozonetel/ozonetel-agent.service.ts` | Modify: add `callControl()`, `pauseRecording()`, `getAbandonCalls()` |
| `src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/call-control`, `POST /api/ozonetel/recording`, `GET /api/ozonetel/missed-calls` |

### Frontend (helix-engage)
| File | Action |
|------|--------|
| `src/components/call-desk/active-call-card.tsx` | Modify: add Transfer button + transfer input, Pause Recording button |
| `src/components/call-desk/transfer-dialog.tsx` | Create: inline transfer form (enter number, confirm) |
| `src/hooks/use-worklist.ts` | Modify: fetch missed calls from Ozonetel API instead of platform |

---

## Task 1: Add call control service methods

Three new methods in the Ozonetel service: `callControl()` (generic), `pauseRecording()`, and `getAbandonCalls()`.

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

- [ ] **Step 1: Add `callControl()` method**

```typescript
async callControl(params: {
    action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
    ucid: string;
    conferenceNumber?: string;
}): Promise<{ status: string; message: string; ucid?: string }> {
    const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
    const did = process.env.OZONETEL_DID ?? '918041763265';
    const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';

    this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);

    try {
        const token = await this.getToken();
        const body: Record<string, string> = {
            userName: this.accountId,
            action: params.action,
            ucid: params.ucid,
            did,
            agentPhoneName,
        };
        if (params.conferenceNumber) {
            body.conferenceNumber = params.conferenceNumber;
        }

        const response = await axios.post(url, body, {
            headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
        });

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

- [ ] **Step 2: Add `pauseRecording()` method**

This uses apiKey in query params, not token auth:

```typescript
async pauseRecording(params: {
    ucid: string;
    action: 'pause' | 'unPause';
}): Promise<{ status: string; message: string }> {
    const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;

    this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);

    try {
        const response = await axios.get(url, {
            params: {
                userName: this.accountId,
                apiKey: this.apiKey,
                action: params.action,
                ucid: params.ucid,
            },
        });

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

- [ ] **Step 3: Add `getAbandonCalls()` method**

```typescript
async getAbandonCalls(params?: {
    fromTime?: string;
    toTime?: string;
    campaignName?: string;
}): Promise<Array<{
    monitorUCID: string;
    type: string;
    status: string;
    campaign: string;
    callerID: string;
    did: string;
    agentID: string;
    agent: string;
    hangupBy: string;
    callTime: string;
}>> {
    const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;

    this.logger.log('Fetching abandon calls');

    try {
        const token = await this.getToken();
        const body: Record<string, string> = { userName: this.accountId };
        if (params?.fromTime) body.fromTime = params.fromTime;
        if (params?.toTime) body.toTime = params.toTime;
        if (params?.campaignName) body.campaignName = params.campaignName;

        const response = await axios.get(url, {
            headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
            data: body,
        });

        const data = response.data;
        if (data.status === 'success' && Array.isArray(data.message)) {
            return data.message;
        }
        return [];
    } catch (error: any) {
        this.logger.error(`Abandon calls failed: ${error.message}`);
        return [];
    }
}
```

- [ ] **Step 4: Type check and commit**

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

```
feat: add call control, recording pause, and abandon calls to Ozonetel service
```

---

## Task 2: Add sidecar REST endpoints

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

- [ ] **Step 1: Add `POST /api/ozonetel/call-control`**

```typescript
@Post('call-control')
async callControl(
    @Body() body: {
        action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
        ucid: string;
        conferenceNumber?: string;
    },
) {
    if (!body.action || !body.ucid) {
        throw new HttpException('action and ucid required', 400);
    }
    if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
        throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
    }

    this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);

    try {
        const result = await this.ozonetelAgent.callControl(body);
        return result;
    } catch (error: any) {
        const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
        throw new HttpException(message, error.response?.status ?? 502);
    }
}
```

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

```typescript
@Post('recording')
async recording(
    @Body() body: { ucid: string; action: 'pause' | 'unPause' },
) {
    if (!body.ucid || !body.action) {
        throw new HttpException('ucid and action required', 400);
    }

    try {
        const result = await this.ozonetelAgent.pauseRecording(body);
        return result;
    } catch (error: any) {
        const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
        throw new HttpException(message, error.response?.status ?? 502);
    }
}
```

- [ ] **Step 3: Add `GET /api/ozonetel/missed-calls`**

Import `Get` from `@nestjs/common`:

```typescript
@Get('missed-calls')
async missedCalls() {
    const result = await this.ozonetelAgent.getAbandonCalls();
    return result;
}
```

- [ ] **Step 4: Type check and commit**

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

```
feat: add call control, recording, and missed calls REST endpoints
```

---

## Task 3: Add Transfer and Pause Recording to active call UI

During an active call, the agent gets two new buttons:
- **Transfer** — opens an inline input for the transfer number, then does CONFERENCE + KICK_CALL
- **Pause Rec** — toggles recording pause

**Files:**
- Create: `helix-engage/src/components/call-desk/transfer-dialog.tsx`
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`

- [ ] **Step 1: Create transfer-dialog.tsx**

A simple inline form: text input for phone number + "Transfer" button. On submit, calls the sidecar's call-control endpoint twice: CONFERENCE (dial the target), then after confirming, KICK_CALL (drop the agent).

```typescript
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneArrowRight, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';

type TransferDialogProps = {
    ucid: string;
    onClose: () => void;
    onTransferred: () => void;
};

export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
    const [number, setNumber] = useState('');
    const [transferring, setTransferring] = useState(false);
    const [stage, setStage] = useState<'input' | 'connected'>('input');

    const handleConference = async () => {
        if (!number.trim()) return;
        setTransferring(true);
        try {
            // Add the target to the conference
            await apiClient.post('/api/ozonetel/call-control', {
                action: 'CONFERENCE',
                ucid,
                conferenceNumber: `0${number.replace(/\D/g, '')}`,
            });
            notify.success('Connected', 'Third party connected. Click Complete to transfer.');
            setStage('connected');
        } catch {
            notify.error('Transfer Failed', 'Could not connect to the target number');
        } finally {
            setTransferring(false);
        }
    };

    const handleComplete = async () => {
        setTransferring(true);
        try {
            // Drop the agent from the call — customer stays with the target
            await apiClient.post('/api/ozonetel/call-control', {
                action: 'KICK_CALL',
                ucid,
                conferenceNumber: `0${number.replace(/\D/g, '')}`,
            });
            notify.success('Transferred', 'Call transferred successfully');
            onTransferred();
        } catch {
            notify.error('Transfer Failed', 'Could not complete transfer');
        } finally {
            setTransferring(false);
        }
    };

    return (
        <div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
            <div className="flex items-center justify-between mb-2">
                <span className="text-xs font-semibold text-secondary">Transfer Call</span>
                <button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary">
                    <FontAwesomeIcon icon={faXmark} className="size-3" />
                </button>
            </div>
            {stage === 'input' ? (
                <div className="flex gap-2">
                    <Input
                        size="sm"
                        placeholder="Enter phone number"
                        value={number}
                        onChange={setNumber}
                    />
                    <Button
                        size="sm"
                        color="primary"
                        isLoading={transferring}
                        onClick={handleConference}
                        isDisabled={!number.trim()}
                    >
                        Connect
                    </Button>
                </div>
            ) : (
                <div className="flex items-center justify-between">
                    <span className="text-xs text-tertiary">Connected to {number}</span>
                    <Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
                        Complete Transfer
                    </Button>
                </div>
            )}
        </div>
    );
};
```

- [ ] **Step 2: Add Transfer and Pause Recording buttons to active call card**

In `active-call-card.tsx`, add imports:
```typescript
import { faPhoneArrowRight, faRecordVinyl } from '@fortawesome/pro-duotone-svg-icons';
import { TransferDialog } from './transfer-dialog';
```

Add state:
```typescript
const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false);
```

In the active call button row (around line 241), add two new buttons before the End button:

```typescript
<Button size="sm" color="secondary"
    iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
    onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
    iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
    onClick={async () => {
        const action = recordingPaused ? 'unPause' : 'pause';
        if (callUcid) {
            apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
        }
        setRecordingPaused(!recordingPaused);
    }}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
```

After the button row, before the AppointmentForm, add the transfer dialog:
```typescript
{transferOpen && callUcid && (
    <TransferDialog
        ucid={callUcid}
        onClose={() => setTransferOpen(false)}
        onTransferred={() => {
            setTransferOpen(false);
            hangup();
            setPostCallStage('disposition');
        }}
    />
)}
```

- [ ] **Step 3: Type check and commit**

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

```
feat: add call transfer and recording pause to active call UI
```

---

## Task 4: Deploy and verify

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

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

- [ ] **Step 2: 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
```

- [ ] **Step 3: Test call transfer**

1. Place an outbound call
2. Click "Transfer" → enter a phone number → "Connect"
3. Third party should ring and join the call
4. Click "Complete Transfer" → agent drops, customer stays with target
5. Disposition form shows

- [ ] **Step 4: Test recording pause**

1. During an active call, click "Pause Rec"
2. Button changes to "Resume Rec" (destructive color)
3. Check Ozonetel reports — recording should have a gap
4. Click "Resume Rec" — recording resumes

- [ ] **Step 5: Test missed calls endpoint**

```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/ozonetel/missed-calls | python3 -m json.tool
```

Verify it returns abandon call data from Ozonetel.

---

## Notes

- **Call Transfer is two-step**: CONFERENCE adds the target, KICK_CALL drops the agent. This is a "warm transfer" — all three parties are briefly connected before the agent drops. For "cold transfer" (blind), we'd CONFERENCE + immediately KICK_CALL without waiting.
- **Recording pause uses apiKey in query params** — different auth pattern from other `/ca_apis/` endpoints. This is the `/CAServices/` path.
- **KICK_CALL note from docs**: "Always pass the agent phone number in the conferenceNumber parameter to use KICK_CALL action." This means to drop the agent, pass the agent's phone number as conferenceNumber. To drop the transferred party, pass their number.
- **Missed calls API** — the `getAbandonCalls` returns today's data by default. For historical data, pass fromTime/toTime.
- **The active call button row is getting crowded** (Mute, Hold, Book Appt, Transfer, Pause Rec, End — 6 buttons). If this is too many, we can group Transfer + Pause Rec under a "More" dropdown.
