Outbound call - waitForParticipant doesn't wait for the participant to answer the call

I’m using the Node SDK. I’m following this doc to make outbound calls:

In my backend, I’m using createDispatch:

const api = new LiveKitAPI({
  host: process.env.LIVEKIT_URL!,
  apiKey: process.env.LIVEKIT_API_KEY!,
  secret: process.env.LIVEKIT_API_SECRET!,
});

await api.agentDispatch.createDispatch(
  roomName,
  agentName,
  { metadata: JSON.stringify(metadata) },
);

In the LiveKit agent, I’m creating the SIP participant and waiting for it to join.

The problem is that waitForParticipant doesn’t wait for the participant to answer the call.

"participant joined" is logged immediately after "waiting for participant", and the agent starts talking even though there’s no human on the call yet.

What am I missing?

I’ve also attached a small repro of the LiveKit agent. I assume a backend repro isn’t necessary since it’s just a single call to createDispatch.

import { ServerOptions, cli, defineAgent, log, voice } from '@livekit/agents';
import { LiveKitAPI } from 'livekit-server-sdk';
import * as openai from '@livekit/agents-plugin-openai';
import { audioEnhancement } from '@livekit/plugins-ai-coustics';
import dotenv from 'dotenv';
import { fileURLToPath } from 'node:url';
import { createAgent } from './agent.ts';

dotenv.config({ path: '.env.local' });

export default defineAgent({
  entry: async (ctx) => {
    const logger = log().child({ name: 'main' });

    await ctx.connect();

    const toNumber = JSON.parse(ctx.job.metadata).phoneNumber;

    logger.info({ toNumber }, 'creating outbound call');

    const api = new LiveKitAPI({
      host: process.env.LIVEKIT_URL!,
      apiKey: process.env.LIVEKIT_API_KEY!,
      secret: process.env.LIVEKIT_API_SECRET!,
    });

    try {
      await api.sip.createSipParticipant(
        process.env.LIVEKIT_SIP_OUTBOUND_TRUNK!,
        toNumber,
        ctx.room.name!,
        {
          participantIdentity: toNumber,
          waitUntilAnswered: true,
        },
      );

      logger.info({ toNumber }, 'waiting for participant');

      await ctx.waitForParticipant(toNumber);

      logger.info({ toNumber }, 'participant joined'); // logged immediately. doesn't wait for participant to answer the call
    } catch (error) {
      logger.error(error);

      ctx.shutdown();

      return;
    }

    const session = new voice.AgentSession({
      llm: new openai.realtime.RealtimeModel({
        model: 'gpt-realtime-2.1',
        voice: 'marin',
        inputAudioTranscription: null,
      }),
    });

    await session.start({
      agent: createAgent(),
      room: ctx.room,
      inputOptions: {
        noiseCancellation: audioEnhancement({ model: 'quailVfS' }),
      },
    });

    logger.info('generating reply');

    session.generateReply({
      instructions: 'Greet the user in a helpful and friendly manner.',
    });
  },
});

cli.runApp(
  new ServerOptions({
    agent: fileURLToPath(import.meta.url),
    agentName: process.env.LIVEKIT_AGENT_NAME!,
  }),
);

livekit-outbound-test.zip (57.8 KB)

waitForParticipant is working correctly, it’s just not the signal you want. The SIP participant is added to the room as soon as your provider accepts the INVITE, which is while it’s still ringing. The docs describe watching the sip.callStatus attribute from that point and waiting for it to turn active.

So drop waitForParticipant entirely. waitUntilAnswered: true is the right mechanism and should block until pickup, throwing SipCallError if the call fails or is rejected.

Which raises why it isn’t blocking for you. Your import is './agent.ts' with the extension, so you’re on Node type stripping or a transpile-only runner. Neither type checks, so if your installed livekit-server-sdk doesn’t have waitUntilAnswered in CreateSipParticipantOptions, it gets silently dropped rather than erroring. Worth checking your installed version, and running tsc --noEmit once to see whether the option is valid against your types.

Easiest way to confirm is to time it:

const t = Date.now();
await api.sip.createSipParticipant(trunk, toNumber, ctx.room.name!, {
  participantIdentity: toNumber,
  waitUntilAnswered: true,
  ringingTimeout: 30,
});
logger.info({ ms: Date.now() - t }, 'createSipParticipant returned');

Answer the phone after a few rings. If that logs tens of milliseconds rather than the seconds you spent ringing, the option isn’t reaching the server.

As a belt-and-braces fix either way, gate session.start on the participant’s sip.callStatus attribute being active rather than on presence. The values are dialing, ringing, active and hangup, and it updates through attribute change events.

Got it, good to know.

tsc --noEmit ran without errors.

The log after createSipParticipant (“createSipParticipant returned”) was logged after 1573 ms - while the call was still ringing, or perhaps before it even started ringing.

livekit-server-sdk version is 2.18.0, which is the latest. I’m not entirely sure I understood why the option would be dropped.

Ideally I’d like to make it work with waitUntilAnswered, but I’ve also tried the workaround you suggested - sip.callStatus is immediately active, before the call is answered:

await api.sip.createSipParticipant(
  process.env.LIVEKIT_SIP_OUTBOUND_TRUNK!,
  toNumber,
  ctx.room.name!,
  {
    participantIdentity: toNumber,
    waitUntilAnswered: true,
    ringingTimeout: 30,
  },
);

const participant = await ctx.waitForParticipant(toNumber);

logger.info(participant.attributes, 'participant.attributes'); // sip.callStatus is immediately "active"

Thanks for testing it. And your sip.callStatus result narrows it usefully.

That attribute is server state rather than something the SDK derives. So both signals agree: the LiveKit server considered the call connected at about 1.5 seconds. The SDK waited and was told it had been answered. That moves the question upstream of your code, which is progress even though it doesn’t fix it yet.

Two explanations fit. Either your provider returned a 200 OK before the human picked up, which is a false answer supervision problem on the carrier side, or the server marked the call active earlier than it should have. Worth noting that a carrier playing ringback as early media should be sending 183 Session Progress, and that alone shouldn’t mark the call answered.

Your own detail decides a lot of it. You said the return may have come before the phone even started ringing. If that’s right, a carrier answer is a harder explanation to sustain, and it points more at the server side.

Fastest way to settle it is the SIP trace. Grab the SCL_ call ID from your dashboard and ask which response came back at roughly 1.5 seconds. A 200 OK makes it a carrier conversation about answer supervision. Anything else and it’s worth a LiveKit issue with that call ID attached.

Also worth logging the attribute repeatedly rather than once, so you can see whether it ever passes through ringing or goes straight to active.

Appreciate the detailed responses.

Perhaps it’s safe to assume the problem is in my code or LiveKit’s SDK / backend rather than with the carrier?

@darryncampbell @CWilson Thoughts?

I agree with @areeb_mohsin1 , understanding what happened at roughly 1.5 seconds would be key to understanding the root cause

Fair enough.

Could you help clarify how I can tell which response came back at roughly 1.5 seconds after I grab the SCL_ call ID? Are you referring to the call events?

Fastest way to settle it is the SIP trace. Grab the SCL_ call ID from your dashboard and ask which response came back at roughly 1.5 seconds.

I’m unsure why the timestamp for ‘Connected’ is identical to ‘call started’, but if you look at the PCAP you can see the 200 OK message is arriving very quickly after the outbound call is established:

Which explains why you are seeing your createSipParticipant with waitUntilAnswered return quickly, since as far as LiveKit is concerned, the call was answered.

If you’re new to SIP, then we have an excellent primer for this in our docs:

Thanks @darryncampbell, that settles it. Worth pulling out the exact number, since it’s the bit that makes this actionable with a carrier.

180 Ringing is at 0.546078 and 200 OK at 0.780186. A 234 millisecond gap. Nobody picks up a phone in 234ms, so this is a false answer, and everything after it follows correctly. LiveKit saw a 200 OK, treated the call as connected, and waitUntilAnswered returned. Nothing in your code or the SDK is misbehaving.

@royibernthal one thing worth ruling out before you take this anywhere: what number were you dialing? If the destination terminates inside your provider’s own network rather than going out to the PSTN, an instant answer can be normal and the trace wouldn’t tell you anything about real calls. Worth repeating against a mobile on an ordinary carrier.

If it reproduces there, it’s a carrier ticket rather than a LiveKit one. Give them the SIP call ID and the 234ms figure and ask about answer supervision on that route.

And if you end up stuck with a route that behaves this way, the workaround is to stop using SIP answer as your cue and wait for speech from the far end before greeting. You lose the proactive greeting, but it survives a carrier that answers early.

@darryncampbell Thanks. I went over the SIP handshake docs and I can make more sense of the PCAP now.

@areeb_mohsin1 Outbound goes through Twilio, which handles the SIP termination. This premature 200 OK issue happens when calling Quo numbers. When calling an ordinary carrier as you suggested things work as expected.

Since the calls originate from Twilio and it seems Twilio isn’t the one sending the 200 OK, doesn’t that mean they’re going out to the PSTN in both cases? LMK if I’m misunderstanding how this works.

Either way, you were right - the problem isn’t with the code. I was too quick to assume that earlier.

I’ll reach out to Quo’s support.

Unfortunately product-wise it’s very important for outbound calls to have a proactive greeting - it’s more than just a greeting in practice. Hopefully this premature 200 OK isn’t too common among popular providers.