# 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 = { 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> { const url = `https://${this.apiDomain}/ca_apis/abandonCalls`; this.logger.log('Fetching abandon calls'); try { const token = await this.getToken(); const body: Record = { 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 (
Transfer Call
{stage === 'input' ? (
) : (
Connected to {number}
)}
); }; ``` - [ ] **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 ``` After the button row, before the AppointmentForm, add the transfer dialog: ```typescript {transferOpen && callUcid && ( 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.