npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cemscale-voip/voip-sdk

v1.41.0

Published

VoIP SDK for CemScale multi-tenant platform — API client, WebRTC, React hooks

Downloads

7,209

Readme

@cemscale-voip/voip-sdk

TypeScript SDK for integrating CemScale VoIP into your application. Includes an HTTP/WebSocket API client, a WebRTC softphone, and React hooks.

Installation

npm install @cemscale-voip/voip-sdk

Authentication

Use an API Key to authenticate. Get one from the dashboard (API Keys page).

import { VoIPClient } from '@cemscale-voip/voip-sdk';

const voip = new VoIPClient({
  apiUrl: 'https://voip-api.cemscale.com',
  apiKey: 'csk_live_your_key_here',
});

// Ready. All methods work immediately.

That's it. No login, no passwords, no tokens to manage.

Examples

Click-to-call

// Call a phone number from extension 1001
const { callUuid } = await voip.originate({
  fromExtension: '1001',
  toNumber: '+15551234567',
});

// Control the call
await voip.holdCall(callUuid);
await voip.transfer(callUuid, { targetExtension: '1002', type: 'blind' });
await voip.hangup(callUuid);

Extensions

const { extensions } = await voip.listExtensions();
const { extension } = await voip.getExtension('ext-id');
await voip.createExtension({ extension: '1004', password: 'SecurePass1!', displayName: 'Dave' });

CRM User Mapping

// Link a CRM user to a VoIP extension
await voip.updateCrmMapping('ext-id', {
  crmUserId: 'crm-user-123',
  crmMetadata: { department: 'Sales' },
});

// Look up extension by CRM user (click-to-call from contact page)
const { extension } = await voip.getExtensionByCrmUser('crm-user-123');
await voip.originate({ fromExtension: extension.extension, toNumber: '+15551234567' });

Real-Time Events (WebSocket)

voip.connectWebSocket();

voip.onWsEvent('call_start', (event) => {
  console.log('New call:', event.data);
});

voip.onWsEvent('presence_change', (event) => {
  console.log(event.data.extension, event.data.status);
});

voip.onWsEvent('call_end', (event) => {
  // Log to CRM
  logCallToCRM(event.data);
});

Call Forwarding & CRM Integration

// List only forwarded calls (calls that hit an extension and were diverted)
const { calls } = await voip.listCalls({ forwarded: 'true' });
for (const call of calls) {
  console.log(`${call.caller_id_number} -> ext ${call.destination} -> FWD ${call.forwarded_to}`);
  // forwarded_to: the external/internal number the call was forwarded to (null if not forwarded)
  // audio_url: recording URL (forwarded calls are now recorded end-to-end)
  if (call.audio_url) {
    const playUrl = voip.getRecordingAudioUrl(call.id);
    console.log(`Recording: ${playUrl}`);
  }
}

// Search by the external number calls were forwarded to
const { calls: fwdCalls } = await voip.listCalls({ forwardedTo: '+1305' });

// Get a single call with forwarding info + recording
const { call } = await voip.getCall('call-uuid');
if (call.forwarded_to) {
  logForwardedCall(call.caller_id_number, call.destination, call.forwarded_to);
  // Play the recording of the forwarded conversation:
  const audioUrl = voip.getRecordingAudioUrl(call.id);
  // => "https://api.example.com/api/recordings/<id>/audio?apiKey=..."
}

Webhooks — Typed Event Payloads

The SDK exports typed interfaces for every webhook event payload. Import them to get full IntelliSense in your CRM webhook handler:

import type {
  WebhookEnvelope,
  CallStartedEvent,
  CallAnsweredEvent,
  CallForwardedEvent,
  CallEndedEvent,
} from '@cemscale-voip/voip-sdk';

// Register a webhook subscription
await voip.createWebhook({
  name: 'CRM Events',
  url: 'https://your-crm.com/api/voip-webhook',
  events: ['call.started', 'call.answered', 'call.forwarded', 'call.ended'],
  secret: 'your-hmac-secret', // optional — enables X-Webhook-Signature header
});

call.forwarded — 3-stage lifecycle

The call.forwarded event fires at up to 3 stages during a forwarded call. Each delivery includes a status field so your CRM knows the current stage:

| Status | When | What it means | |---|---|---| | initiated | Call created | Forward will happen (only for always type) | | answered | Forwarded party picks up | Forward is connected (all types) | | completed | Call ends | Full CDR data: duration, recording, hangup cause |

// Express webhook handler with full type safety
app.post('/api/voip-webhook', (req, res) => {
  const envelope = req.body as WebhookEnvelope;

  switch (envelope.event) {
    case 'call.forwarded': {
      const fwd = envelope.data as CallForwardedEvent;
      // fwd.status: 'initiated' | 'answered' | 'completed'
      // fwd.forwardedTo: '+13055559999'
      // fwd.forwardType: 'always' | 'no-answer' | 'busy'
      // fwd.originalDestination: '1001' (the extension)
      // fwd.caller: '+17861234567' (who called)

      if (fwd.status === 'initiated') {
        // Real-time: show "Forwarding to +1305..." in CRM UI
        updateCrmCallStatus(fwd.callUuid, `Forwarding to ${fwd.forwardedTo}`);
      }

      if (fwd.status === 'answered') {
        // Forward connected — update CRM
        updateCrmCallStatus(fwd.callUuid, `Connected to ${fwd.forwardedTo}`);
      }

      if (fwd.status === 'completed') {
        // Call ended — save CDR with recording
        saveCdrWithForwarding({
          callUuid: fwd.callUuid,
          forwardedTo: fwd.forwardedTo,
          duration: fwd.duration,       // seconds (only on completed)
          hangupCause: fwd.hangupCause, // only on completed
          recordingFile: fwd.recordingFile, // only on completed
        });
      }
      break;
    }

    case 'call.ended': {
      const ended = envelope.data as CallEndedEvent;
      // ended.forwardedTo is non-null when the call was forwarded
      saveCdr(ended);
      break;
    }

    case 'call.answered': {
      const answered = envelope.data as CallAnsweredEvent;
      // answered.forwardedTo is present when a forwarded call was answered
      if (answered.forwardedTo) {
        console.log(`Forwarded call answered: ${answered.forwardedTo}`);
      }
      break;
    }
  }

  res.sendStatus(200);
});

Webhook payload examples

call.forwarded (status: completed):

{
  "event": "call.forwarded",
  "timestamp": "2026-04-07T14:15:02Z",
  "data": {
    "callUuid": "a1b2c3d4-...",
    "caller": "+17861234567",
    "originalDestination": "1001",
    "forwardedTo": "+13055559999",
    "forwardType": "always",
    "status": "completed",
    "duration": 142,
    "billsec": 138,
    "hangupCause": "NORMAL_CLEARING",
    "recordingFile": "/var/lib/voip-recordings/.../uuid.wav",
    "inboundDid": "+17862757830",
    "timestamp": "2026-04-07T14:17:24Z"
  }
}

call.ended (with forwarding):

{
  "event": "call.ended",
  "timestamp": "2026-04-07T14:17:24Z",
  "data": {
    "callUuid": "a1b2c3d4-...",
    "direction": "inbound",
    "caller": "+17861234567",
    "destination": "+17862757830",
    "duration": 142,
    "billsec": 138,
    "hangupCause": "NORMAL_CLEARING",
    "status": "completed",
    "forwardedTo": "+13055559999",
    "recordingFile": "/var/lib/voip-recordings/.../uuid.mp3"
  }
}

Call Queues

const { queues } = await voip.listQueues();
const { stats } = await voip.getQueueStats('queue-id');
console.log(`Agents: ${stats.agents.available}/${stats.agents.total}`);
console.log(`Service level: ${stats.today.serviceLevel}%`);

Business Hours

await voip.createSchedule({
  name: 'Office Hours',
  timezone: 'America/New_York',
  schedules: [
    { day: 'monday', enabled: true, startTime: '09:00', endTime: '17:00' },
    { day: 'tuesday', enabled: true, startTime: '09:00', endTime: '17:00' },
  ],
  afterHoursAction: 'voicemail',
});

const { isOpen } = await voip.getScheduleStatus('schedule-id');

Blocklist

await voip.blockNumber({ number: '+15559999999', reason: 'Spam', direction: 'inbound' });
const { blocked } = await voip.checkBlocked('+15559999999'); // true

AI Agents (Voice AI)

Create and manage AI phone agents powered by Gemini. Agents answer calls, speak to callers in real-time, and can transfer to humans.

No extra configuration needed. AI Agents work through the same voip client — no separate BRIDGE_URL or BRIDGE_API_KEY required.

// List all AI agents
const { agents } = await voip.listAiAgents();
console.log(agents);
// [{ agent_id: 'acme-sales', display_name: 'Sofia', voice: 'Kore', ... }]

// Create a new AI agent
const { agent } = await voip.createAiAgent({
  agent_id: 'acme-sales',           // unique slug (used as route target)
  display_name: 'Sofia',             // name shown in dashboard/logs
  company: 'Acme Corp',              // company context for the AI
  role: 'Sales Representative',      // role context
  language: 'en',                    // 'en', 'es', 'fr', etc.
  voice: 'Kore',                     // Gemini voice: Kore, Aoede, Charon, Fenrir, Puck
  speak_first: true,                 // agent greets the caller first
  thinking_level: 'minimal',         // minimal, low, medium, high
  temperature: 0.7,                  // 0.0 (precise) to 1.0 (creative)
  system_prompt: `You are Sofia, a professional sales representative for Acme Corp.
You speak English and Spanish fluently. Respond in whichever language the caller uses.
Be conversational, warm, and helpful. Keep responses SHORT (1-2 sentences).
If the caller asks to speak with a human, transfer them.
Start with: "Thank you for calling Acme Corp, how can I help you today?"`,
});

// Update an existing agent
await voip.updateAiAgent('acme-sales', {
  display_name: 'Sofia v2',
  system_prompt: 'Updated instructions...',
  voice: 'Aoede',
  temperature: 0.5,
});

// Get a single agent
const { agent: detail } = await voip.getAiAgent('acme-sales');
console.log(detail.system_prompt);

// Delete an agent
await voip.deleteAiAgent('acme-sales');

// Health check (verify AI Bridge is connected)
const health = await voip.aiAgentsHealth();
console.log(health.status); // 'connected'

Assigning an AI Agent to a Phone Number

After creating an agent, link it to a DID so inbound calls are answered by the AI:

// Assign agent to DID — calls to this number now go to the AI
await voip.updateDid('did-uuid', {
  inboundRoute: 'ai_agent',
  routeTarget: 'acme-sales',         // must match agent_id
  aiTransferType: 'queue',            // where AI transfers when caller asks for human
  aiTransferTarget: 'support-queue-uuid',
});

// Transfer types: 'extension', 'queue', 'ringgroup', 'ivr', 'external', 'voicemail'
// Transfer target: the UUID/number of the destination

Complete Example: Full Agent Setup

import { VoIPClient } from '@cemscale-voip/voip-sdk';

const voip = new VoIPClient({
  apiUrl: process.env.VOIP_API_URL,   // https://voip-api.cemscale.com
  apiKey: process.env.VOIP_API_KEY,   // your API key
});

// 1. Create the agent
const { agent } = await voip.createAiAgent({
  agent_id: 'support-bot',
  display_name: 'Alex',
  company: 'My Company',
  language: 'en',
  voice: 'Kore',
  speak_first: true,
  thinking_level: 'minimal',
  system_prompt: 'You are Alex, a support agent for My Company. Help callers with their issues. Transfer to a human only if the caller explicitly asks.',
});

// 2. Link agent to a phone number
await voip.updateDid('did-uuid-here', {
  inboundRoute: 'ai_agent',
  routeTarget: 'support-bot',
  aiTransferType: 'extension',
  aiTransferTarget: '1001',
});

// Now when someone calls that number, Alex (the AI) answers.
// If the caller says "let me talk to a person", Alex transfers to extension 1001.

Available Voices

| Voice | Style | |-------|-------| | Kore | Professional, warm (default) | | Aoede | Friendly, energetic | | Charon | Deep, authoritative | | Fenrir | Calm, measured | | Puck | Upbeat, playful |

Agent Fields Reference

| Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | agent_id | string | Yes | — | Unique slug identifier (e.g. acme-sales) | | display_name | string | Yes | — | Display name in dashboard and logs | | company | string | No | display_name | Company name for AI context | | role | string | No | AI Assistant | Role context for the AI | | language | string | No | en | Language code: en, es, fr, etc. | | voice | string | No | Kore | Gemini voice name | | system_prompt | string | No | '' | Instructions for the AI agent | | speak_first | boolean | No | true | Agent greets caller first | | thinking_level | string | No | minimal | Reasoning depth: minimal, low, medium, high | | temperature | number | No | 0.7 | Creativity: 0.0 (precise) to 1.0 (creative) | | tools | string[] | No | [] | List of tools the agent can use (see below) | | tools_webhook_url | string | No | '' | URL where tool invocations are sent via POST |

AI Agent Tools (Function Calling)

During a phone call, the AI agent can invoke tools — actions that do something in the real world. When Gemini decides to call a tool, the platform either handles it internally (transfer, hangup) or sends a POST request to your tools_webhook_url and waits for your response. Gemini then uses your response to continue the conversation naturally.

There are 7 standard tools available:

| Tool Name | Type | Description | Webhook? | |-----------|------|-------------|----------| | transfer_to_human | Built-in | Transfers the live call to a human agent at the configured extension via PBX | No — handled internally by the platform | | end_call | Built-in | Ends the phone call after the AI says goodbye. Triggers after 2 seconds to let final audio play | No — handled internally by the platform | | create_lead | Webhook | Creates a new lead/prospect. Gemini collects first_name, last_name, phone, email, company, interest, notes from the caller | Yes — POST to your tools_webhook_url | | leave_message | Webhook | Records a message. Gemini collects caller_name, caller_phone, message content, urgency, and who it's for | Yes — POST to your tools_webhook_url | | schedule_appointment | Webhook | Books an appointment. Gemini collects caller_name, phone, preferred date, time, duration, purpose | Yes — POST to your tools_webhook_url | | check_policy_status | Webhook | Looks up a policy/account status. Gemini collects policy_number, phone, last_name, date_of_birth, lookup_type | Yes — POST to your tools_webhook_url | | send_sms | Webhook | Sends an SMS text message to the caller during the live call. Gemini collects to_phone, message content, purpose, caller_name | Yes — POST to your tools_webhook_url |

Complete flow — what happens when a tool is invoked:

EXAMPLE: Caller wants to leave a message

1. Caller says: "I'd like to leave a message for the sales team"
2. Gemini (AI) says: "Sure, I can take that for you. What's your name?"
3. Caller says: "Juan Garcia"
4. Gemini says: "Got it, Juan. And what's the best number to reach you?"
5. Caller says: "305-123-4567"
6. Gemini says: "Perfect. What's the message you'd like me to pass along?"
7. Caller says: "I'm interested in the premium plan, please have someone call me back"
8. Gemini says: "Let me confirm — your message for the sales team is that you're
   interested in the premium plan and you'd like a callback at 305-123-4567. Is that right?"
9. Caller says: "Yes, that's correct"

10. >>> Gemini invokes the tool: leave_message({
      caller_name: "Juan Garcia",
      caller_phone: "+13051234567",
      for_person: "sales team",
      message: "Interested in the premium plan, requesting callback",
      urgent: false,
      callback_preferred_time: "anytime"
    })

11. >>> Platform sends POST to YOUR tools_webhook_url:
    POST https://your-crm.com/api/ai-tools-webhook
    Content-Type: application/json

    {
      "function": "leave_message",
      "args": {
        "caller_name": "Juan Garcia",
        "caller_phone": "+13051234567",
        "for_person": "sales team",
        "message": "Interested in the premium plan, requesting callback",
        "urgent": false,
        "callback_preferred_time": "anytime"
      },
      "call_id": "ff6a1693-b2f8-123f-b8b1-0a7dee2809a7",
      "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
    }

12. >>> YOUR app processes it, saves to database, and responds:
    {
      "status": "saved",
      "message": "Message recorded successfully. The sales team will call Juan back today."
    }

13. >>> Gemini reads your response and tells the caller:
    "All set, Juan. I've passed your message to the sales team.
     They'll give you a call back today. Is there anything else?"

14. Caller says: "No, that's all. Thanks!"

15. >>> Gemini invokes: end_call({ reason: "conversation_complete", summary: "Took message for sales team from Juan Garcia" })

16. >>> Platform waits 2 seconds for final audio, then hangs up the call.

Example 2: SMS confirmation during a call

EXAMPLE: Caller schedules an appointment and wants a text confirmation

1. Caller says: "Can I schedule a consultation for Friday?"
2. Gemini (AI): "Of course! What time works best for you?"
3. Caller: "Around 2pm would be great"
4. Gemini: "Perfect. I have you down for Friday at 2pm. Can I get your name?"
5. Caller: "It's Ana Martinez"
6. Gemini: "Thank you Ana. And your phone number?"
7. Caller: "786-555-0198"

8. >>> Gemini invokes: schedule_appointment({...})
9. >>> Your app responds: { status: "scheduled", message: "Confirmed for Friday at 2pm." }

10. Gemini: "You're all set for Friday at 2pm, Ana. Would you like me
    to send you a text confirmation with the details?"
11. Caller: "Yes please, send it to this number"

12. >>> Gemini invokes: send_sms({
      to_phone: "+17865550198",
      message: "Hi Ana! Your consultation is confirmed for Friday at 2:00 PM at our office. Address: 123 Main St. Reply HELP for questions.",
      purpose: "appointment_confirmation",
      caller_name: "Ana Martinez"
    })

13. >>> Platform sends POST to YOUR tools_webhook_url:
    POST https://your-crm.com/api/ai-tools-webhook
    Content-Type: application/json

    {
      "function": "send_sms",
      "args": {
        "to_phone": "+17865550198",
        "message": "Hi Ana! Your consultation is confirmed for Friday at 2:00 PM...",
        "purpose": "appointment_confirmation",
        "caller_name": "Ana Martinez"
      },
      "call_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
    }

14. >>> YOUR app sends the SMS via Twilio/Telnyx/etc and responds:
    { "status": "sent", "message": "The text has been sent to your phone." }

15. >>> Gemini tells the caller:
    "Done! I just sent you a text with your appointment details.
     Is there anything else I can help you with?"

16. Caller: "No that's it, thank you!"

17. >>> Gemini invokes: end_call({ reason: "conversation_complete", summary: "Scheduled Friday 2pm consultation for Ana Martinez, sent SMS confirmation" })

Step 1 — Create the agent with tools enabled:

const { agent } = await voip.createAiAgent({
  agent_id: 'acme-receptionist',
  display_name: 'Sofia',
  company: 'Acme Corp',
  language: 'en',
  voice: 'Sulafat',
  speak_first: true,
  thinking_level: 'minimal',
  temperature: 0.5,

  // Enable the tools you want this agent to use
  tools: [
    'transfer_to_human',   // Transfer to human when caller asks
    'end_call',            // Hang up when conversation is done
    'create_lead',         // Create leads in your CRM
    'leave_message',       // Take messages from callers
    'schedule_appointment', // Book appointments
    'send_sms',              // Send SMS confirmations during calls
  ],

  // YOUR endpoint that receives tool calls — you MUST build this
  tools_webhook_url: 'https://your-app.com/api/ai-tools-webhook',

  system_prompt: `You are Sofia, a professional receptionist for Acme Corp.
You are warm, natural, and efficient. Keep responses to 1-2 sentences.
You can help callers with:
- Leaving a message for the team
- Scheduling an appointment
- Answering general questions about Acme Corp
- Creating a lead when a new prospect calls
If the caller asks to speak with a person, transfer them.
When the conversation is complete, end the call politely.`,
});

Step 2 — Build the webhook endpoint in your app:

Your app needs ONE endpoint that receives ALL tool calls. The function field tells you which tool was invoked. The args field contains the data Gemini collected from the caller. The call_id and tenant_id let you track which call and tenant triggered the action.

// In your app (Express, Fastify, Next.js API route, etc.)

app.post('/api/ai-tools-webhook', async (req, res) => {
  const { function: toolName, args, call_id, tenant_id } = req.body;

  console.log(`[AI Tool] ${toolName} called during call ${call_id}`);
  console.log(`[AI Tool] Args:`, args);

  try {
    switch (toolName) {

      // ─── CREATE LEAD ──────────────────────────────────────────
      // Gemini collected: first_name, last_name (optional), phone,
      //   email (optional), company (optional), notes (optional),
      //   interest (optional), source (optional)
      case 'create_lead': {
        const lead = await db.leads.create({
          data: {
            firstName:  args.first_name,
            lastName:   args.last_name || '',
            phone:      args.phone,
            email:      args.email || null,
            company:    args.company || null,
            notes:      args.notes || null,
            interest:   args.interest || null,
            source:     'ai_phone_call',
            callId:     call_id,
            tenantId:   tenant_id,
            createdAt:  new Date(),
          },
        });

        // The "message" field is what Gemini reads back to the caller
        return res.json({
          status: 'created',
          lead_id: lead.id,
          message: `I've created a record for ${args.first_name}. ` +
                   `A team member will follow up shortly.`,
        });
      }

      // ─── LEAVE MESSAGE ────────────────────────────────────────
      // Gemini collected: caller_name, caller_phone, for_person (optional),
      //   message, urgent (boolean, optional),
      //   callback_preferred_time (optional)
      case 'leave_message': {
        const msg = await db.messages.create({
          data: {
            callerName:    args.caller_name,
            callerPhone:   args.caller_phone,
            forPerson:     args.for_person || null,
            content:       args.message,
            urgent:        args.urgent || false,
            callbackTime:  args.callback_preferred_time || 'anytime',
            callId:        call_id,
            tenantId:      tenant_id,
            createdAt:     new Date(),
          },
        });

        // Optional: send a real-time notification to your team
        await notifyTeam({
          type: args.urgent ? 'URGENT_MESSAGE' : 'NEW_MESSAGE',
          from: args.caller_name,
          phone: args.caller_phone,
          message: args.message,
        });

        return res.json({
          status: 'saved',
          message_id: msg.id,
          message: args.urgent
            ? `I've flagged this as urgent. The team will get back to ${args.caller_name} right away.`
            : `Message recorded. Someone from the team will call ${args.caller_name} back soon.`,
        });
      }

      // ─── SCHEDULE APPOINTMENT ─────────────────────────────────
      // Gemini collected: caller_name, phone, date, time (optional),
      //   duration (optional, default "30 minutes"),
      //   purpose, notes (optional)
      case 'schedule_appointment': {
        // Check availability in your calendar system
        const isAvailable = await calendar.checkAvailability(
          args.date,
          args.time,
        );

        if (!isAvailable) {
          // Tell Gemini the slot is not available — it will offer alternatives
          return res.json({
            status: 'unavailable',
            message: `That time slot is not available. ` +
                     `We have openings in the morning between 9 and 12, ` +
                     `or in the afternoon between 2 and 4. ` +
                     `Would any of those work?`,
          });
        }

        const appointment = await calendar.create({
          name:     args.caller_name,
          phone:    args.phone,
          date:     args.date,
          time:     args.time || '10:00 AM',
          duration: args.duration || '30 minutes',
          purpose:  args.purpose,
          notes:    args.notes || null,
          callId:   call_id,
          tenantId: tenant_id,
        });

        return res.json({
          status: 'scheduled',
          appointment_id: appointment.id,
          message: `Appointment confirmed for ${args.date} at ${args.time}. ` +
                   `${args.caller_name} will receive a confirmation shortly.`,
        });
      }

      // ─── CHECK POLICY STATUS ──────────────────────────────────
      // Gemini collected: policy_number, phone (optional),
      //   last_name (optional), date_of_birth (optional),
      //   lookup_type (optional: status/coverage/renewal/payment/claims)
      case 'check_policy_status': {
        const policy = await db.policies.findFirst({
          where: { policyNumber: args.policy_number },
        });

        if (!policy) {
          return res.json({
            status: 'not_found',
            message: `I couldn't find a policy with number ${args.policy_number}. ` +
                     `Could you double-check that number?`,
          });
        }

        return res.json({
          status: 'found',
          policy_number: policy.policyNumber,
          policy_status: policy.status,        // e.g. "active", "expired", "pending"
          renewal_date: policy.renewalDate,
          balance_due: policy.balanceDue,
          message: `Policy ${args.policy_number} is currently ${policy.status}. ` +
                   `${policy.renewalDate ? 'Renewal date is ' + policy.renewalDate + '.' : ''} ` +
                   `${policy.balanceDue ? 'Balance due: $' + policy.balanceDue + '.' : 'No balance due.'}`,
        });
      }


      // --- SEND SMS -------------------------------------------------
      // Gemini collected: to_phone, message, purpose (optional),
      //   caller_name (optional)
      case 'send_sms': {
        // Send the SMS via your SMS provider (Twilio, Telnyx, etc.)
        const smsResult = await smsProvider.send({
          to:   args.to_phone,
          body: args.message,
          // Use your tenant's DID or a dedicated SMS number as the sender
          from: tenant.smsNumber || tenant.mainDid,
        });

        // Log it in your CRM
        await db.smsLogs.create({
          data: {
            toPhone:    args.to_phone,
            message:    args.message,
            purpose:    args.purpose || 'general',
            callerName: args.caller_name || null,
            callId:     call_id,
            tenantId:   tenant_id,
            status:     smsResult.success ? 'sent' : 'failed',
            createdAt:  new Date(),
          },
        });

        if (!smsResult.success) {
          return res.json({
            status: 'failed',
            message: "I was unable to send the text message right now. " +
                     "Let me take note of your number and we will send it to you shortly.",
          });
        }

        return res.json({
          status: 'sent',
          sms_id: smsResult.id,
          message: "The text message has been sent to " + args.to_phone + ". " +
                   "Please check your messages in a moment.",
        });
      }

      // ─── UNKNOWN TOOL ─────────────────────────────────────────
      default: {
        console.warn(`[AI Tool] Unknown tool: ${toolName}`);
        return res.json({
          status: 'unknown',
          message: `I've noted your request. A team member will follow up.`,
        });
      }
    }
  } catch (error) {
    console.error(`[AI Tool] Error handling ${toolName}:`, error);
    return res.status(500).json({
      status: 'error',
      message: `I'm having trouble processing that right now. ` +
               `Let me take a note and have someone follow up with you.`,
    });
  }
});

Step 3 — Link the agent to a phone number (DID):

// Assign the agent to answer a specific phone number
await voip.updateDid('did-uuid-here', {
  inboundRoute: 'ai_agent',
  routeTarget: 'acme-receptionist',  // must match agent_id
  aiTransferType: 'extension',       // where transfer_to_human sends the call
  aiTransferTarget: '1001',          // extension number to transfer to
});

Webhook request your app receives (POST):

{
  "function": "create_lead",
  "args": {
    "first_name": "Maria",
    "last_name": "Rodriguez",
    "phone": "+17865551234",
    "email": "[email protected]",
    "company": "Rodriguez LLC",
    "interest": "Premium shipping plan",
    "notes": "Called asking about bulk shipping rates for e-commerce business"
  },
  "call_id": "ff6a1693-b2f8-123f-b8b1-0a7dee2809a7",
  "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
}

Webhook response your app must return (JSON):

{
  "status": "created",
  "message": "Lead created for Maria Rodriguez. A sales rep will follow up within the hour."
}

Important rules for the webhook response:

  • The message field is what Gemini speaks to the caller. Write it naturally, as if a person is saying it on the phone.
  • Keep the message short — 1-2 sentences maximum.
  • If something fails, still return a message with a graceful fallback so Gemini can tell the caller.
  • Your webhook has a 10 second timeout. If it doesn't respond in time, Gemini gets: "Action has been recorded. A team member will follow up."
  • If no tools_webhook_url is configured on the agent, webhook tools still "work" but always return the generic fallback message.

Tool arguments reference:

create_lead args: | Arg | Type | Required | Description | |-----|------|----------|-------------| | first_name | string | Yes | Caller's first name | | last_name | string | No | Caller's last name | | phone | string | Yes | Phone number | | email | string | No | Email if provided | | company | string | No | Company name | | interest | string | No | What they're interested in | | notes | string | No | Additional notes | | source | string | No | Lead source (default: "phone_call") |

leave_message args: | Arg | Type | Required | Description | |-----|------|----------|-------------| | caller_name | string | Yes | Who is leaving the message | | caller_phone | string | Yes | Callback number | | for_person | string | No | Who the message is for | | message | string | Yes | Message content | | urgent | boolean | No | Whether caller said it's urgent | | callback_preferred_time | string | No | When they prefer a callback |

schedule_appointment args: | Arg | Type | Required | Description | |-----|------|----------|-------------| | caller_name | string | Yes | Who the appointment is for | | phone | string | Yes | Contact phone | | date | string | Yes | Preferred date ("tomorrow", "Thursday", "2026-04-18") | | time | string | No | Preferred time ("2pm", "morning", "10:30 AM") | | duration | string | No | Duration ("30 minutes", "1 hour") | | purpose | string | Yes | Reason for appointment | | notes | string | No | Additional notes |

check_policy_status args: | Arg | Type | Required | Description | |-----|------|----------|-------------| | policy_number | string | Yes | Policy or account number | | phone | string | No | Phone for verification | | last_name | string | No | Last name for verification | | date_of_birth | string | No | DOB for verification | | lookup_type | string | No | What to look up: "status", "coverage", "renewal", "payment", "claims" |

send_sms args: | Arg | Type | Required | Description | |-----|------|----------|-------------| | to_phone | string | Yes | Phone number to send SMS to, with country code (e.g. "+17865551234") | | message | string | Yes | Text message content. Max 160 chars recommended for single SMS | | purpose | string | No | Why the SMS is being sent: "appointment_confirmation", "address_info", "reference_number", "follow_up_link", "general" | | caller_name | string | No | Caller name for personalization and logging |

transfer_to_human args (no webhook — handled internally): | Arg | Type | Required | Description | |-----|------|----------|-------------| | reason | string | Yes | Why the caller wants to transfer | | department | string | No | Specific department if mentioned |

end_call args (no webhook — handled internally): | Arg | Type | Required | Description | |-----|------|----------|-------------| | reason | string | Yes | Why: "conversation_complete", "caller_goodbye", "no_response", "voicemail_detected" | | summary | string | No | Brief summary of the call |


CRM Webhook Setup — Receiving AI Tool Calls

When an AI agent invokes a tool during a live call (e.g. send_sms, create_lead), the VoIP platform sends a POST request to your CRM. Your CRM must have a webhook endpoint that receives these tool calls, processes them, and returns a response that Gemini speaks to the caller.

Architecture:

Caller speaks -> Gemini AI -> invokes tool (e.g. send_sms)
  -> VoIP Platform (Go) -> POST to VoIP API
  -> VoIP API forwards -> POST to YOUR CRM webhook
  -> Your CRM processes (sends SMS, creates lead, etc.)
  -> Your CRM responds with JSON
  -> Gemini reads response to caller

CRITICAL: Your webhook endpoint must NOT have global auth middleware.

The VoIP platform authenticates via Authorization: Bearer <api_key> where api_key is a VoIP SDK API key (csk_live_...). Your webhook handler must validate this token itself — do NOT put it behind your CRM's global auth middleware (e.g. session auth, JWT, capability checks). If your CRM's router wraps routes with automatic auth, register this endpoint WITHOUT that wrapper.

Environment variable your CRM needs:

# The VoIP SDK API key used to authenticate tool call webhooks.
# This key is created in the VoIP platform and shared with your CRM.
# The VoIP platform sends it as: Authorization: Bearer <this_key>
VOIP_TOOLS_WEBHOOK_KEY=csk_live_da3d1ee6d3b51696922206dc139beab84239a7935049b5a5

Complete webhook handler your CRM needs (Express/Fastify/etc.):

import crypto from 'crypto';

// ──────────────────────────────────────────────────────────
// IMPORTANT: Register this route WITHOUT global auth middleware.
// This endpoint has its own auth via validateToolsWebhookAuth().
// If your router uses a `wrap()` or `requireAuth()` middleware,
// do NOT apply it to this route.
// ──────────────────────────────────────────────────────────

/**
 * Validate that the request comes from the VoIP platform.
 * Checks Authorization: Bearer <api_key> against VOIP_TOOLS_WEBHOOK_KEY env var.
 */
function validateToolsWebhookAuth(req: { headers: Record<string, any> }): boolean {
  const expectedKey = process.env.VOIP_TOOLS_WEBHOOK_KEY;
  if (!expectedKey) return false;

  // Method 1: Authorization Bearer token
  const authHeader = req.headers.authorization || req.headers.Authorization;
  if (authHeader) {
    const token = typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
      ? authHeader.slice(7)
      : authHeader;
    if (typeof token === 'string' && token.length === expectedKey.length) {
      try {
        return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedKey));
      } catch { return false; }
    }
  }

  // Method 2: X-API-Key header
  const apiKeyHeader = req.headers['x-api-key'];
  if (typeof apiKeyHeader === 'string' && apiKeyHeader.length === expectedKey.length) {
    try {
      return crypto.timingSafeEqual(Buffer.from(apiKeyHeader), Buffer.from(expectedKey));
    } catch { return false; }
  }

  return false;
}

// Register WITHOUT global auth middleware:
// WRONG: router.post('/api/webhooks/ai-tools', requireAuth, handler)
// RIGHT: router.post('/api/webhooks/ai-tools', handler)
// If using a `wrap()` function that enforces auth, do NOT use it here.

app.post('/api/webhooks/ai-tools', async (req, res) => {
  // 1. Validate auth
  if (!validateToolsWebhookAuth(req)) {
    return res.status(401).json({ status: 'error', message: 'Unauthorized' });
  }

  // 2. Parse the tool call
  const { function: toolName, args, call_id, tenant_id } = req.body;

  if (!toolName || !args) {
    return res.status(400).json({ status: 'error', message: 'Missing function or args' });
  }

  console.log(`[AI Tool] ${toolName} called during call ${call_id} (tenant: ${tenant_id})`);

  try {
    switch (toolName) {

      // ─── SEND SMS ──────────────────────────────────────────────
      // Gemini collected: to_phone (required), message (required),
      //   purpose (optional), caller_name (optional)
      //
      // When to use: The caller asks "can you send me that by text?"
      // or the AI decides to send a confirmation after booking, etc.
      case 'send_sms': {
        const toPhone = args.to_phone;
        const messageText = args.message;

        if (!toPhone || !messageText) {
          return res.json({
            status: 'error',
            message: 'I need the phone number and message to send. Could you provide those?',
          });
        }

        // ── Send the SMS using YOUR SMS provider ──
        // Replace this with your actual SMS sending code.
        // Examples: Twilio, Telnyx, Vonage, AWS SNS, etc.
        //
        // Twilio example:
        //   const twilio = require('twilio')(TWILIO_SID, TWILIO_AUTH);
        //   const sms = await twilio.messages.create({
        //     to: toPhone,
        //     from: YOUR_TWILIO_NUMBER,
        //     body: messageText,
        //   });
        //
        // Telnyx example:
        //   const telnyx = require('telnyx')(TELNYX_API_KEY);
        //   const sms = await telnyx.messages.create({
        //     to: toPhone,
        //     from: YOUR_TELNYX_NUMBER,
        //     text: messageText,
        //   });

        // YOUR SMS SENDING CODE HERE:
        const smsResult = await sendSms({
          to: toPhone,
          message: messageText,
        });

        // Log the SMS in your database
        // await db.smsLogs.create({ toPhone, message: messageText,
        //   purpose: args.purpose, callerName: args.caller_name,
        //   callId: call_id, tenantId: tenant_id, status: smsResult.success ? 'sent' : 'failed' });

        if (!smsResult.success) {
          return res.json({
            status: 'failed',
            message: 'I was unable to send the text message right now. ' +
                     'Let me take note and we will send it to you shortly.',
          });
        }

        return res.json({
          status: 'sent',
          message: 'The text message has been sent to ' + toPhone + '. ' +
                   'You should receive it in a moment.',
        });
      }

      // ─── CREATE LEAD ──────────────────────────────────────────
      case 'create_lead': {
        // args: first_name (required), last_name, phone (required),
        //   email, company, interest, notes, source
        const lead = await createLeadInCrm({
          firstName: args.first_name,
          lastName: args.last_name,
          phone: args.phone,
          email: args.email,
          company: args.company,
          interest: args.interest,
          notes: args.notes,
          callId: call_id,
          tenantId: tenant_id,
        });

        return res.json({
          status: 'created',
          lead_id: lead.id,
          message: `I've created a record for ${args.first_name}. A team member will follow up shortly.`,
        });
      }

      // ─── LEAVE MESSAGE ────────────────────────────────────────
      case 'leave_message': {
        // args: caller_name (required), caller_phone (required),
        //   message (required), for_person, urgent, callback_preferred_time
        const msg = await saveMessageInCrm({
          callerName: args.caller_name,
          callerPhone: args.caller_phone,
          message: args.message,
          forPerson: args.for_person,
          urgent: args.urgent,
          callbackTime: args.callback_preferred_time,
          callId: call_id,
          tenantId: tenant_id,
        });

        return res.json({
          status: 'saved',
          message: args.urgent
            ? `I've flagged this as urgent. The team will get back to ${args.caller_name} right away.`
            : `Message recorded. Someone from the team will call ${args.caller_name} back soon.`,
        });
      }

      // ─── SCHEDULE APPOINTMENT ─────────────────────────────────
      case 'schedule_appointment': {
        // args: caller_name (required), phone (required), date (required),
        //   time, duration, purpose (required), notes
        const appt = await scheduleAppointmentInCrm({
          callerName: args.caller_name,
          phone: args.phone,
          date: args.date,
          time: args.time,
          duration: args.duration,
          purpose: args.purpose,
          notes: args.notes,
          callId: call_id,
          tenantId: tenant_id,
        });

        return res.json({
          status: 'scheduled',
          message: `Appointment confirmed for ${args.date}${args.time ? ' at ' + args.time : ''}. ` +
                   `${args.caller_name} will receive a confirmation shortly.`,
        });
      }

      // ─── CHECK POLICY STATUS ──────────────────────────────────
      case 'check_policy_status': {
        // args: policy_number (required), phone, last_name, date_of_birth, lookup_type
        const policy = await lookupPolicyInCrm({ policyNumber: args.policy_number });

        if (!policy) {
          return res.json({
            status: 'not_found',
            message: `I couldn't find a policy with number ${args.policy_number}. Could you double-check that number?`,
          });
        }

        return res.json({
          status: 'found',
          message: `Policy ${args.policy_number} is currently ${policy.status}.`,
        });
      }

      // ─── UNKNOWN TOOL ─────────────────────────────────────────
      default: {
        console.warn(`[AI Tool] Unknown tool: ${toolName}`);
        return res.json({
          status: 'noted',
          message: "I've noted your request. A team member will follow up.",
        });
      }
    }
  } catch (error: any) {
    console.error(`[AI Tool] Error handling ${toolName}:`, error);
    return res.status(500).json({
      status: 'error',
      message: "I'm having trouble processing that right now. Let me take a note and have someone follow up.",
    });
  }
});

Webhook request format (what your CRM receives):

Every tool call arrives as a POST with this JSON body:

{
  "function": "send_sms",
  "args": {
    "to_phone": "+17865550198",
    "message": "Your appointment is confirmed for Friday at 2:00 PM. Address: 123 Main St.",
    "purpose": "appointment_confirmation",
    "caller_name": "Ana Martinez"
  },
  "call_id": "ff6a1693-b2f8-123f-b8b1-0a7dee2809a7",
  "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
}

Headers your CRM receives:

| Header | Value | Description | |--------|-------|-------------| | Content-Type | application/json | Always JSON | | Authorization | Bearer csk_live_... | VoIP SDK API key for auth | | X-Webhook-Source | voiceai-platform | Identifies the source |

Webhook response format (what your CRM must return):

Your response MUST be JSON. The message field is what Gemini speaks to the caller:

{
  "status": "sent",
  "message": "The text message has been sent to your phone. You should receive it in a moment."
}

| Field | Type | Required | Description | |-------|------|----------|-------------| | status | string | Yes | Result: "sent", "created", "saved", "scheduled", "found", "not_found", "error", "failed" | | message | string | Yes | CRITICAL: This is what the AI says to the caller. Write naturally, 1-2 sentences max. |

Response rules:

  • The message field is spoken by the AI to the caller. Write it as natural speech.
  • Keep message to 1-2 sentences maximum.
  • If something fails, STILL return a message with a graceful fallback.
  • Your webhook has a 9 second timeout. If it doesn't respond, Gemini hears: "I'm having trouble processing that right now."
  • HTTP status should be 200 for success, 500 for errors. The AI always gets the message field either way.

Testing your webhook with curl:

# Test send_sms tool call
curl -X POST https://your-crm.com/api/webhooks/ai-tools \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_VOIP_TOOLS_WEBHOOK_KEY" \
  -d '{
    "function": "send_sms",
    "args": {
      "to_phone": "+17865551234",
      "message": "Your appointment is confirmed for Friday at 2pm.",
      "purpose": "appointment_confirmation",
      "caller_name": "Juan Garcia"
    },
    "call_id": "test-123",
    "tenant_id": "your-tenant-id"
  }'

# Expected response:
# {"status":"sent","message":"The text message has been sent to +17865551234."}

CDR Export

const csv = await voip.exportCalls({
  dateFrom: '2026-03-01',
  dateTo: '2026-03-31',
  direction: 'inbound',
});

API Key Management

// Create a key for a new integration
const { apiKey } = await voip.createApiKey({ name: 'Mobile App', role: 'readonly' });
console.log(apiKey.key); // csk_live_... — save this, shown only once

// List all keys
const { apiKeys } = await voip.listApiKeys();

// Revoke a key
await voip.revokeApiKey('key-id');

Connecting a WebRTC Softphone

Your app doesn't need to know or store SIP passwords. Use the API key to fetch credentials on the fly:

// 1. Get SIP credentials for the extension (API key handles auth)
const { sipCredentials } = await voip.getSipCredentialsByNumber('1001');

// sipCredentials = {
//   extension:    "1001",
//   displayName:  "Alice",
//   password:     "auto-provided",   <-- API returns it, your app doesn't store it
//   sipDomain:    "sip.cemscale.com",
//   wsUri:        "wss://sip.cemscale.com/ws",
//   registrar:    "sip:sip.cemscale.com"
// }

// 2. Also get TURN credentials for NAT traversal
const turn = await voip.getTurnCredentials();

Two methods available:

| Method | Use when | |--------|----------| | getSipCredentials(extensionId) | You have the extension UUID | | getSipCredentialsByNumber('1001') | You have the extension number |

WebRTC Softphone (Browser Only)

For building a browser-based softphone, use the WebRTCPhone class or the useVoIP React hook. These require SIP credentials (extension + password), not an API key.

Check microphone access

Before starting the phone, check that the user has granted microphone permission:

import { WebRTCPhone } from '@cemscale-voip/voip-sdk';

const hasAccess = await WebRTCPhone.checkMicrophoneAccess();
if (!hasAccess) {
  alert('Microphone access is required to make calls.');
}

React hook example

import { useVoIP } from '@cemscale-voip/voip-sdk';

function PhoneWidget() {
  const { isRegistered, currentCall, login, startPhone, call, answer, hangup, toggleHold, toggleMute, error } = useVoIP({
    apiUrl: 'https://voip-api.cemscale.com',
    sipDomain: 'sip.cemscale.com',
    wsUri: 'wss://sip.cemscale.com/ws',
  });

  return (
    <div>
      <p>{isRegistered ? 'Online' : 'Offline'}</p>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {currentCall ? (
        <>
          <button onClick={hangup}>Hangup</button>
          <button onClick={toggleHold}>{currentCall.held ? 'Resume' : 'Hold'}</button>
          <button onClick={toggleMute}>{currentCall.muted ? 'Unmute' : 'Mute'}</button>
        </>
      ) : (
        <button onClick={() => call('+15551234567')}>Call</button>
      )}
    </div>
  );
}

Reliability features (built-in)

The WebRTCPhone handles these scenarios automatically:

| Feature | Behavior | |---------|----------| | SIP transport reconnection | Auto-reconnects up to 3 times (4s delay) if the WebSocket drops | | Auto re-registration | Re-sends SIP REGISTER after transport reconnects | | Media recovery | Reattaches remote audio after hold/unhold or renegotiation via pc.ontrack | | ICE monitoring | Emits error event when ICE connection enters failed or disconnected state | | Autoplay detection | Emits an error event (AUTOPLAY_BLOCKED) if the browser blocks audio playback | | Hold guard | Prevents hold/unhold race conditions from rapid toggling | | Busy call rejection | Incoming calls during active call are rejected with SIP 486 and emit callStateChanged |

WebRTCPhone events

Listen for events via the on() method:

const phone = new WebRTCPhone(config);

phone.on('registered', () => console.log('SIP registered'));
phone.on('registrationFailed', (error) => console.error('Registration failed:', error));
phone.on('callStateChanged', (callInfo) => console.log('Call state:', callInfo.state));
phone.on('error', (err) => console.error('Phone error:', err));

| Event | Payload | When | |-------|---------|------| | registered | none | SIP registration succeeds (including after reconnect) | | registrationFailed | Error | SIP registration fails or re-registration after reconnect fails | | callStateChanged | WebRTCCallInfo | Call state changes (ringing, answered, held, terminated, etc.) | | error | Error | ICE failure, autoplay blocked, media error |


## All Methods

### Calls
| Method | Description |
|--------|-------------|
| `originate({ fromExtension, toNumber })` | Start a call |
| `hangup(uuid)` | Hang up |
| `transfer(uuid, { targetExtension, type })` | Transfer (blind/attended) |
| `holdCall(uuid, hold?)` | Hold / resume |
| `parkCall(uuid, slot?)` | Park a call |
| `getParkedCalls()` | List parked calls |
| `listCalls(params?)` | CDR history |
| `getCall(id)` | Single CDR record |
| `getActiveCalls()` | Active calls |
| `getCallStats(period?)` | Call statistics |
| `exportCalls(params?)` | Export CDR as CSV |

### Extensions
| Method | Description |
|--------|-------------|
| `listExtensions()` | List all |
| `getExtension(id)` | Get detail |
| `createExtension(params)` | Create |
| `updateExtension(id, params)` | Update |
| `deleteExtension(id)` | Delete |
| `setCallForward(id, params)` | Set forwarding |
| `getCallForward(id)` | Get forwarding |
| `getExtensionByCrmUser(crmUserId)` | Lookup by CRM user |
| `updateCrmMapping(id, params)` | Set CRM mapping |
| `getSipCredentials(id)` | SIP credentials by extension UUID |
| `getSipCredentialsByNumber('1001')` | SIP credentials by extension number |

### Conferences
| Method | Description |
|--------|-------------|
| `listConferences()` | List active |
| `getConference(name)` | Detail + members |
| `joinConference(name, callUuid)` | Add call to conference |
| `transferToConference(uuid, name?)` | Transfer call to conference |
| `kickFromConference(name, memberId)` | Remove member |
| `muteConferenceMember(name, memberId, mute)` | Mute/unmute |
| `deafConferenceMember(name, memberId, deaf)` | Deaf/undeaf |
| `lockConference(name, lock)` | Lock/unlock |
| `recordConference(name, action)` | Start/stop recording |

### Ring Groups
| Method | Description |
|--------|-------------|
| `listRingGroups()` | List all |
| `createRingGroup(params)` | Create |
| `updateRingGroup(id, params)` | Update |
| `deleteRingGroup(id)` | Delete |

### Call Queues
| Method | Description |
|--------|-------------|
| `listQueues()` | List all |
| `createQueue(params)` | Create |
| `updateQueue(id, params)` | Update |
| `deleteQueue(id)` | Delete |
| `pauseQueueAgent(queueId, agentId, paused)` | Pause/unpause |
| `loginQueueAgent(queueId, agentId, loggedIn)` | Login/logout |
| `getQueueStats(queueId)` | Real-time stats |

### IVR
| Method | Description |
|--------|-------------|
| `listIvrMenus()` | List all |
| `getIvrMenu(id)` | Get detail |
| `createIvrMenu(params)` | Create |
| `updateIvrMenu(id, params)` | Update |
| `deleteIvrMenu(id)` | Delete |
| `uploadIvrAudio(id, file, filename)` | Upload greeting audio |
| `uploadIvrInvalidAudio(id, file, filename)` | Upload invalid-input audio |
| `getIvrAudioUrl(id)` | Get greeting audio URL |
| `getIvrInvalidAudioUrl(id)` | Get invalid-input audio URL |
| `downloadIvrAudio(id)` | Download greeting audio |
| `downloadIvrInvalidAudio(id)` | Download invalid-input audio |
| `deleteIvrAudio(id)` | Delete greeting audio |
| `deleteIvrInvalidAudio(id)` | Delete invalid-input audio |
| `generateIvrTts(id, params)` | Generate TTS greeting |
| `listIvrVoices()` | List available TTS voices |

### Webhooks
| Method | Description |
|--------|-------------|
| `listWebhooks()` | List all |
| `createWebhook(params)` | Create |
| `updateWebhook(id, params)` | Update |
| `deleteWebhook(id)` | Delete |
| `testWebhook(id)` | Send test event |
| `listWebhookDeliveries(id)` | Delivery history |

### Voicemail
| Method | Description |
|--------|-------------|
| `listVoicemails(params?)` | List messages |
| `getVoicemail(id)` | Get single |
| `getVoicemailAudioUrl(id)` | Audio URL |
| `getVoicemailDownloadUrl(id)` | Download URL |
| `getVoicemailCount(extension?)` | Unread/total |
| `markVoicemailRead(id)` | Mark as read |
| `deleteVoicemail(id)` | Delete |
| `bulkDeleteVoicemails(params?)` | Bulk delete |
| `getVoicemailGreeting(extension?)` | Get greeting settings |
| `updateVoicemailGreeting(params)` | Update greeting settings |
| `uploadVoicemailGreetingAudio(file, ext?, name?)` | Upload custom greeting |
| `getVoicemailGreetingAudioUrl(extension)` | Greeting audio URL |
| `deleteVoicemailGreetingAudio(extension?)` | Delete greeting audio |

### Blocklist
| Method | Description |
|--------|-------------|
| `listBlockedNumbers()` | List blocked |
| `blockNumber(params)` | Block a number |
| `unblockNumber(id)` | Unblock |
| `checkBlocked(number)` | Check if blocked |

### Business Hours
| Method | Description |
|--------|-------------|
| `listSchedules()` | List schedules |
| `getSchedule(id)` | Get detail |
| `createSchedule(params)` | Create |
| `updateSchedule(id, params)` | Update |
| `deleteSchedule(id)` | Delete |
| `getScheduleStatus(id)` | Open or closed? |

### SIP Trunks
| Method | Description |
|--------|-------------|
| `listTrunks()` | List trunks |
| `getTrunk(id)` | Get detail |
| `createTrunk(params)` | Create |
| `updateTrunk(id, params)` | Update |
| `deleteTrunk(id)` | Delete |
| `getTrunkStatus(id)` | Check gateway status |
| `syncTrunk(id)` | Sync config to FreeSWITCH |
| `listActiveGateways()` | List active gateways |

### API Keys
| Method | Description |
|--------|-------------|
| `listApiKeys()` | List keys |
| `getApiKeyDetail(id)` | Get detail |
| `createApiKey(params)` | Create (returns full key once) |
| `updateApiKey(id, params)` | Update |
| `revokeApiKey(id)` | Delete |
| `regenerateApiKey(id)` | Regenerate (new key) |

### DIDs
| Method | Description |
|--------|-------------|
| `listDids()` | List numbers |
| `createDid(params)` | Assign DID |
| `updateDid(id, params)` | Update routing |
| `deleteDid(id)` | Remove |

### AI Agents
| Method | Description |
|--------|-------------|
| `listAiAgents(tenantId?)` | List all AI agents |
| `getAiAgent(agentId)` | Get agent details (includes `system_prompt`) |
| `createAiAgent(params)` | Create new AI agent |
| `updateAiAgent(agentId, params)` | Update agent config |
| `deleteAiAgent(agentId)` | Delete agent |
| `aiAgentsHealth()` | Check AI Bridge connectivity |

### Tenants (superadmin)
| Method | Description |
|--------|-------------|
| `listTenants()` | List all tenants |
| `getTenant(id)` | Get detail |
| `createTenant(params)` | Create tenant |
| `updateTenant(id, params)` | Update tenant |
| `deleteTenant(id)` | Delete tenant |
| `getTenantStats(id)` | Tenant statistics |

### Recordings
| Method | Description |
|--------|-------------|
| `listRecordings(params?)` | List recordings |
| `getRecordingUrl(id)` | Get presigned S3 URL |
| `getRecordingAudioUrl(id)` | Direct audio stream URL |
| `getRecordingDownloadUrl(id)` | Download URL |
| `deleteRecording(id)` | Delete |

### Presence
| Method | Description |
|--------|-------------|
| `getPresence()` | Simple map |
| `getPresenceDetailed()` | Detailed with call info |

### Reports
| Method | Description |
|--------|-------------|
| `getDashboardStats()` | Dashboard numbers |
| `getCallsByDay(params?)` | Daily breakdown |
| `getTopExtensions(params?)` | Most active extensions |

## WebSocket Events

| Event | Data |
|-------|------|
| `presence_snapshot` | Full presence state on connect |
| `presence_change` | `{ extension, status, callUuid }` |
| `registration_change` | `{ extension, registered, ip, timestamp }` |
| `call_start` | `{ callUuid, caller, destination, direction }` |
| `call_answer` | `{ callUuid }` |
| `call_end` | `{ callUuid, duration, hangupCause }` |

### WebSocket reconnection

The `VoIPClient` WebSocket reconnects automatically with exponential backoff:

- Initial delay: **3 seconds**, max: **60 seconds**
- Max retries: **10** before emitting an error event and stopping
- Call `connectWebSocket()` again to reset the retry counter

## React Hooks

### `useVoIP`

All-in-one hook that combines `VoIPClient` + `WebRTCPhone`. Returns `client`, `phone`, `isRegistered`, `currentCall`, `error`, and action methods (`login`, `startPhone`, `call`, `answer`, `hangup`, `toggleHold`, `toggleMute`, `sendDtmf`, `blindTransfer`, `originate`, etc.).

### `usePresence`

Real-time extension presence via WebSocket with REST polling fallback. Automatically handles `presence_snapshot`, `presence_change`, and `registration_change` events.

```tsx
import { usePresence } from '@cemscale-voip/voip-sdk/hooks';

const { presence, getStatus, isRealtime } = usePresence(client);
// presence: Map<extension, status>
// getStatus('1001') => 'available' | 'on_call' | 'ringing' | 'offline'

useCallStatus

Real-time active call tracking via WebSocket.

import { useCallStatus } from '@cemscale-voip/voip-sdk/hooks';

const { activeCalls, recentCalls, stats, activeCount } = useCallStatus(client);

Feature Codes (Phone Dial Pad)

| Code | Function | |------|----------| | *3 | Conference room | | *70 | Park call | | *71XX | Retrieve parked call from slot XX | | *97 | Check voicemail |