Files
helix-engage/docs/superpowers/plans/2026-03-21-cc-agent-features.md
saridsa2 3064eeb444 feat: CC agent features, live call assist, worklist redesign, brand tokens
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>
2026-03-21 10:36:10 +05:30

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
  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
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.