CC Agent: - Call transfer (CONFERENCE + KICK_CALL) with inline transfer dialog - Recording pause/resume during active calls - Missed calls API (Ozonetel abandonCalls) - Call history API (Ozonetel fetchCDRDetails) Live Call Assist: - Deepgram Nova STT via raw WebSocket - OpenAI suggestions every 10s with lead context - LiveTranscript component in sidebar during calls - Browser audio capture from remote WebRTC stream Worklist: - Redesigned table: clickable phones, context menu (Call/SMS/WhatsApp) - Last interaction sub-line, source column, improved SLA - Filtered out rows without phone numbers - New missed call notifications Brand: - Logo on login page - Blue scale rebuilt from logo blue rgb(32, 96, 160) - FontAwesome duotone CSS variables set globally - Profile menu icons switched to duotone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
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
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:
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
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
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
@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
@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:
@Get('missed-calls')
async missedCalls() {
const result = await this.ozonetelAgent.getAbandonCalls();
return result;
}
- Step 4: Type check and commit
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).
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:
import { faPhoneArrowRight, faRecordVinyl } from '@fortawesome/pro-duotone-svg-icons';
import { TransferDialog } from './transfer-dialog';
Add state:
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:
<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:
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
hangup();
setPostCallStage('disposition');
}}
/>
)}
- Step 3: Type check and commit
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
cd helix-engage-server && npm run build
# tar + scp + docker cp + restart
- Step 2: Build and deploy frontend
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
- Place an outbound call
- Click "Transfer" → enter a phone number → "Connect"
- Third party should ring and join the call
- Click "Complete Transfer" → agent drops, customer stays with target
- Disposition form shows
- Step 4: Test recording pause
- During an active call, click "Pause Rec"
- Button changes to "Resume Rec" (destructive color)
- Check Ozonetel reports — recording should have a gap
- Click "Resume Rec" — recording resumes
- Step 5: Test missed calls endpoint
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
getAbandonCallsreturns 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.