Threll.ai
Guides

End-to-End Integration: Inbound

Handle calls your customers make to you. Unlike outbound, an inbound call isn't started through the REST API — it begins the moment someone dials your worker's number. Your webhook endpoint does all the live work: recognizing the caller, serving tools, and logging results.

The scenario: your receptionist worker Snorre answers calls on the company number. When a call comes in, you identify the caller by phone number, inject their history as context, let Snorre check the calendar and book appointments through your tools, and sync everything to your CRM when the call ends.

Covering outbound instead? See End-to-End Integration: Outbound. The webhook handler is largely shared between the two — the difference is who initiates the call and what you do in call.worker_request.

Anatomy of one inbound call

No REST request from you — the timeline starts with the customer dialing. Blue dots are sync events (Threll waits for your response); green dots are async notifications.

RINGCustomer dials your worker's inbound number

The call enters Threll via your telephony integration. Nothing required from you yet.

call.worker_requestsync

Fired on inbound call entry, before the worker picks up. customer.number is the caller's number — your moment to recognize them and return configuration overrides.

call.status_updateasync

Lifecycle changes stream in as the worker answers — direction on the call is inbound.

call.transcriptasync

Transcript turns arrive as they're committed, for both speakers.

call.tool_callsync

Snorre invokes your external tools — check_availability, then book_appointment. The caller waits on each response.

call.endedasync

The final event. Now fetch the results over REST.

GET…/phone-calls/{id} · …/transcript · …/audio/url

You pull the analysis, transcript, and recording, and log the interaction in your CRM.

Step 1 — Set up

Step 2 — Identify the caller

For inbound calls, call.worker_request is where the integration earns its keep. The payload gives you the caller's number — look them up and return configuration overrides so the worker answers with full context:

Handle call.worker_request
case 'call.worker_request': {
  // data.direction === 'inbound' — customer.number is the caller ID
  const caller = await crm.findByPhone(data.customer.number);

  if (caller) {
    return res.json({
      context: [
        `Caller is ${caller.firstName} ${caller.lastName} (${caller.tier} customer).`,
        `Open items: ${caller.openTickets.join(', ') || 'none'}.`,
        `Last appointment: ${caller.lastAppointment ?? 'never'}.`,
      ].join('\n'),
    });
  }

  // Unknown number — let the worker handle it as a new caller
  return res.json({
    context: 'Caller not found in CRM. Treat as a new lead and collect their name.',
  });
}
if event["type"] == "call.worker_request":
    data = event["data"]
    # data["direction"] == "inbound" — customer.number is the caller ID
    caller = crm.find_by_phone(data["customer"]["number"])

    if caller:
        context = (
            f"Caller is {caller.first_name} {caller.last_name} "
            f"({caller.tier} customer).\n"
            f"Open items: {', '.join(caller.open_tickets) or 'none'}.\n"
            f"Last appointment: {caller.last_appointment or 'never'}."
        )
    else:
        context = (
            "Caller not found in CRM. Treat as a new lead "
            "and collect their name."
        )

    return jsonify({"context": context})

The caller is on the line while this request is in flight — keep your CRM lookup fast, and respond with an empty object {} rather than stalling if you have nothing to add.

Step 3 — Serve mid-call tools

Booking is a two-tool dance: the worker first checks availability, offers the caller a slot, then books it. Each invocation arrives as a call.tool_call and pauses the conversation until you respond:

Handle call.tool_call
case 'call.tool_call': {
  const { name, arguments: args, toolCallId } = data;
  log.info('tool call', toolCallId, name, args);

  switch (name) {
    case 'check_availability': {
      const slots = await calendar.freeSlots(args.date);
      return res.json({ result: { slots } });
    }
    case 'book_appointment': {
      const booking = await calendar.book({
        slot: args.slot,
        reason: args.reason,
      });
      return res.json({
        result: { confirmed: true, reference: booking.reference },
      });
    }
    default:
      return res.json({ result: { error: `unknown tool ${name}` } });
  }
}
if event["type"] == "call.tool_call":
    data = event["data"]
    name, args = data["name"], data["arguments"]

    if name == "check_availability":
        slots = calendar.free_slots(args["date"])
        return jsonify({"result": {"slots": slots}})

    if name == "book_appointment":
        booking = calendar.book(
            slot=args["slot"],
            reason=args.get("reason"),
        )
        return jsonify({
            "result": {"confirmed": True, "reference": booking.reference}
        })

    return jsonify({"result": {"error": f"unknown tool {name}"}})

Step 4 — Finalize when the call ends

Identical to the outbound flow: when call.ended arrives, wait for postProcessingStatus to reach completed, then fetch the call, transcript, and audio URL and write them to your CRM. The outbound guide's finalizeCall works unchanged.

Two inbound-specific touches worth adding:

Inbound vs. outbound at a glance

AspectOutboundInbound
Call startsYou: POST …/phone-callsCustomer dials the worker's number
call.worker_requestRefresh context for a call you plannedIdentify an unexpected caller by customer.number
Customer recordYou supply it in the create requestOften unknown — look up by phone, create after the call
direction fieldoutboundinbound
After call.endedSame: poll post-processing, fetch transcript + audio, sync to CRM

Reference

Reference: REST API · Webhooks