Telephony: - Track UCID from SIP headers and ManualDial response - Submit disposition to Ozonetel via Set Disposition API (ends ACW) - Fix outboundPending flag lifecycle to prevent inbound poisoning - Fix render order: post-call UI takes priority over active state - Pre-select disposition when appointment booked during call Appointment form: - Convert from slideout to inline collapsible below call card - Fetch real doctors from platform, filter by department - Show time slot availability grid (booked slots greyed + strikethrough) - Double-check availability before booking - Support edit and cancel existing appointments UI: - Add Force Ready button to profile menu (logout+login to clear ACW) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 KiB
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)
- 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).
- 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.
- 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.
- Duplicate Call records — Both the webhook (server-side) and the disposition form (client-side) create Call records in the platform for the same call.
- "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:
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:
// 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:
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:
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
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/disposeendpoint
This endpoint receives disposition from the frontend and does two things in parallel:
- Calls Ozonetel Set Disposition (to end ACW)
- Creates Call record on platform (moves this from frontend to sidecar)
@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-readyendpoint
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
dialendpoint to return UCID
The ManualDial response already includes ucid. Ensure the controller returns it:
// 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:
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:
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):
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:
// 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):
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
cd helix-engage-server && npx tsc --noEmit
cd helix-engage && npx tsc --noEmit
- Step 2: Build and deploy sidecar
cd helix-engage-server
npm run build
# tar + scp + docker cp + restart
- Step 3: 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
# tar + scp to /opt/fortytwo/helix-engage-frontend
- Step 4: Test outbound flow
- Login as rekha.cc@globalhospital.com
- Click Call on a lead
- SIP should auto-answer (outbound bridge)
- Talk, then End → Disposition form
- Select disposition → "Back to Worklist"
- Check sidecar logs:
Set disposition response: {"status":"Success"} - Immediately place another outbound call — should work without waiting
- Step 5: Test inbound flow
- After "Back to Worklist" from above
- Call the DID from a phone
- Browser should ring (NOT auto-answer)
- Answer → talk → End → Disposition → "Back to Worklist"
- Check sidecar logs: disposition submitted, ACW released
- Call again — should ring within seconds
- Step 6: Test inbound-only flow (no prior outbound)
- Hard refresh → login
- Call the DID
- Should ring, answer, disposition, back to worklist
- 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:
- Add dispositions in CloudAgent admin: Campaigns → Inbound_918041763265 → General Info → Dispositions
- Add:
Appointment Booked,Follow Up,Info Provided,Not Interested,Wrong Number,No Answer - 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.