SIP Telephony — Zero to Hero v1 · 2026-04-25
PART 04 · STEPS 41–60

Asterisk Fundamentals

Asterisk is the soft-PBX in front of every Freya deployment. Twenty steps to read its config without flinching: modules, dialplan, pjsip endpoints, transports, AMI/ARI, and the CLI commands you reach for at 3 a.m.

20 steps 3 demos ~35 minutes
41modules

Asterisk modular architecture

Asterisk is a single daemon that loads modules from modules.conf. Every protocol, codec, and dialplan application is a shared object the daemon dlopens at start. Nothing is hard-coded — even SIP itself is just a module.

The seven modules that matter for a Freya deployment:

  • chan_pjsip.so — modern SIP channel driver. This is what we use.
  • chan_websocket.so — Sangoma fork module that lets the dialplan dial into a WebSocket as if it were a SIP peer. The seam between Asterisk and the Freya agent.
  • res_pjsip.so — the PJSIP resource. Ties chan_pjsip to the configuration tree.
  • res_http_websocket.so — server-side WebSocket support inside Asterisk.
  • app_dial.so — the Dial() dialplan application.
  • app_mixmonitor.so — call recording (mixed user + agent into a single WAV).
  • chan_sip.sodeprecated. We noload it so it cannot conflict on UDP 5060.
Try this on KKB
$ docker exec freya-asterisk asterisk -rx "module show like chan"
42dialplan

The dialplan: contexts, extensions, priorities

The dialplan is a cookbook for what to do when a call shows up. It lives in extensions.conf and is organised into contexts (named scopes) containing extensions (patterns to match the dialed number) executing priorities (numbered steps).

  • [from-trunk] — context name. Referenced from pjsip.conf as context = from-trunk.
  • exten => _+90X.,1,... — extension pattern. Leading _ marks it as a pattern. 1 is the priority.
  • same => n,... — continue at the next priority on the same extension. The n is literally "next".
  • ApplicationsNoOp, Set, MixMonitor, Dial, Hangup. Each one mutates the channel.
Demo 1 · Dialplan stepper
[from-trunk]  1exten => _+90X.,1,NoOp(Inbound from ${CALLERID(num)})  2 same => n,Answer()  3 same => n,Set(CHANNEL(language)=tr)  4 same => n,MixMonitor(${UNIQUEID}-mixed.wav)  5 same => n,Dial(WebSocket/ai_media/c(ulaw)f(json))  6 same => n,Hangup()

Channel state

stateRINGING
extension+905321234567
CALLERID(num)+905321234567
CHANNEL(language)
recording
bridge
last app
Step through priorities one at a time. Answer picks up, Set writes a channel variable, MixMonitor starts recording, Dial bridges to the WebSocket leg, Hangup tears down.
43variables

Variables and channel variables

The dialplan has its own variable system. Most important distinction: regular vs channel-scoped vs inheritance markers.

; basic variables
exten => s,1,Set(CALLDIR=outbound)
 same => n,Set(CHANNEL(hangup_handler_push)=hangup-handler,s,1)
 same => n,Goto(do-call,${EXTEN},1)
  • ${VAR} reads the current value of VAR.
  • Set(VAR=value) writes a regular variable, scoped to the current channel.
  • ${CHANNEL(field)} — read/write a channel-scoped function (e.g. language, hangup_handler_push).
  • ${CALLERID(num)} — read just the numeric part of the caller-ID URI.
  • _VAR (single underscore) — inherit to the immediate child channel.
  • __VAR (double underscore) — inherit forever, down every child.

Inheritance matters when a Dial() creates a second outbound leg and you want your custom variables to follow the call.

44inspect cycle

The INSPECT cycle

A channel arriving in a context starts at priority 1 of the matched extension. The dialplan engine reads, dispatches, advances. Every application has side effects — Set mutates state, Dial blocks until the far end answers or fails, MixMonitor spawns a recording thread — and those side effects can change what the next priority sees.

This is the "interpreter" model. Most of the issues you will debug in production come from misordered priorities (e.g. MixMonitor placed after Dial never starts because Dial blocks) or unset variables (a ${PJSIP_HEADER(read,...)} on a header that the carrier did not send returns empty, and a GotoIf on it fails silently).

Read the dialplan top-down with the same eyes you would read a synchronous async-await chain.

45chan_pjsip

chan_pjsip vs chan_sip

The 2007–2014 chan_sip is the legacy SIP module. chan_pjsip is the modern replacement built on the PJSIP library — better support for IPv6, multiple registrars, TLS, NAPTR/SRV, and a cleaner config tree.

We noload chan_sip in modules.conf so the two cannot fight over UDP 5060. Always use chan_pjsip for new endpoints. If you see SIP/... instead of PJSIP/... in core show channels, you are looking at the wrong module.

46pjsip sections

pjsip.conf section types

A pjsip.conf is just a list of named sections. Each section declares its kind via type=. The five core types — every Freya deployment uses at least four of them:

type = transport
Listening sockets

UDP, TCP, TLS, or WSS bind addresses. bind=0.0.0.0:5060. Where Asterisk accepts SIP.

type = endpoint
A peer (UA)

Codec list, dialplan context, NAT settings, language. The bulk of your config lives here.

type = aor
Address-of-Record

Where the contact lives. Static contact=sip:host:port for outgoing trunks; dynamic for registered phones.

type = auth
Digest credentials

Username + password for SIP digest auth. Skipped for IP-only trunks.

type = identify
IP-based match

Map a source IP (CIDR) to an endpoint. Used when the trunk has no auth and no AOR registers.

47endpoint templates

Endpoint templates & the Garanti-tuned config

To stay DRY, factor shared settings into a template with the (!) suffix and inherit from it with (name). This is the pattern in every customer ConfigMap.

Demo 2 · pjsip.conf annotator — hover any setting
freya defaults garanti-tuned
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060

[endpoint-defaults](!)             ; (!) = template, never used directly
type=endpoint
context=from-trunk
disallow=all
allow=ulaw,alaw
language=tr
direct_media=no
rtp_symmetric=yes
force_rport=no
rewrite_contact=no

[providers](endpoint-defaults)        ; inherit template
transport=transport-udp
trust_id_inbound=yes
aors=providers-aor

[providers-aor]
type=aor
contact=sip:194.49.126.26:5060

[providers-identify]
type=identify
endpoint=providers
identify_by=ip
match=10.231.16.153/32

hover or click a setting

No setting selected

Each setting in pjsip.conf tunes one piece of behaviour. Hover any underlined value to see what it does and why we set it that way for KKB / Garanti / Anadolu.

Standard knob Garanti-tuned
The four amber-tinted knobs — direct_media, rtp_symmetric, force_rport, rewrite_contact — are the ones we explicitly invert from Asterisk defaults to make the call work behind the Garanti SBC.
48aor + contact

AOR (Address of Record) and contacts

An AOR holds the static URI a peer is reachable at. The naming comes from the SIP RFC — your SIP "phone number" (sip:alice@example.com) is an AOR, and where Alice's phone is right now (the IP and port) is the AOR's contact.

For incoming trunk peers you usually do not need a separate AOR — you match by IP and respond on the same socket. For outgoing calls the AOR's contact = sip:host:port is exactly where Asterisk sends the INVITE.

[providers]
type=aor
contact=sip:194.49.126.26:5060

If a peer registers (a softphone, a desk phone), Asterisk fills in the contact dynamically from each REGISTER.

49auth modes

IP-based vs digest auth

Carrier trunks usually identify by source IP — no password. Customer phones registering against your registrar identify with username/password (digest auth). Both can coexist in the same pjsip.conf.

; carrier trunk: IP-based
[providers-identify]
type=identify
endpoint=providers
match=10.231.16.153/32         ; Garanti KASP IP

; user phone: digest
[user1]
type=auth
auth_type=userpass
username=user1
password=secret

For Freya, every customer SBC trunk we have integrated to so far is IP-only. We have not yet shipped a registrar use case in production.

50match=

The match= directive

The match= line on a type=identify section accepts CIDR. One match= per /32 line is the safe pattern. Do not comma-separate.

At Anadolu we hit a Genesys SBC quirk where comma lists were not parsed at all — every other call landed on the wrong endpoint. Splitting them onto separate lines fixed it immediately.

[anadolu-identify]
type=identify
endpoint=providers
match=10.1.137.60/32
match=10.1.137.61/32
match=<genesys-1>/32
match=<genesys-2>/32
match=<genesys-3>/32
match=<genesys-4>/32
51transports

Transports: UDP, TCP, TLS, WS, WSS

SIP is transport-agnostic. PJSIP supports five transports, each with its default port and typical use case.

UDP
:5060
Default for most carriers. Lossy on bad networks.
TCP
:5060
Forced by some SBCs (Anadolu after firewall opened on UDP). Reliable for large INVITEs.
TLS
:5061
Encrypted SIP. Required for SIPS URIs and security-sensitive trunks.
WS
:8088
Plain WebSocket SIP. Browsers, but only over HTTP/local.
WSS
:8089
Secure WebSocket SIP. Browser-side WebRTC clients.
[transport-udp]
type=transport
protocol=udp
bind=0.0.0.0:5060

[transport-tcp]
type=transport
protocol=tcp
bind=0.0.0.0:5060

Some carriers silently require TCP. Anadolu's A20 trap: firewall was opened on UDP, INVITEs failed, dropping to TCP fixed it. Always confirm transport with the carrier in writing — never assume UDP.

For browser-side WebRTC you would add transport-wss on 8089. We currently do not — browser callers are tested via the dashboard's LiveKit room, bypassing Asterisk.

52extensions.conf

extensions.conf basics

The dialplan glue. Each context is a list of extensions; each extension is a sequence of priorities executed top-to-bottom.

[from-internal]
exten => 100,1,Answer()
 same => n,Playback(hello-world)
 same => n,Hangup()

exten => 101,1,Answer()
 same => n,Dial(WebSocket/ai_media/c(ulaw)f(json))
 same => n,Hangup()

exten => 102,1,Answer()
 same => n,Echo()
 same => n,Hangup()
  • 100 — hello-world. Plays a built-in sound and hangs up.
  • 101 — dial the AI agent over the WebSocket leg.
  • 102Echo(). Loops your audio back to you.

Echo() is a lifesaver for proving "audio reaches Asterisk and returns" without the agent in the loop. Always have it on a test extension before you debug the agent.

53direction

Direction-aware routing with custom headers

Our trunks carry both inbound and outbound traffic on the same SIP socket. We use a custom SIP header — X-Freya-Direction — to disambiguate.

exten => _X.,1,NoOp(Direction is ${PJSIP_HEADER(read,X-Freya-Direction)})
 same => n,GotoIf($["${PJSIP_HEADER(read,X-Freya-Direction)}" = "outbound"]?outbound,1)
 same => n,Goto(inbound,1)
  • ${PJSIP_HEADER(read,Header-Name)} — read a header from the incoming INVITE.
  • Set(PJSIP_HEADER(add,X-Header)=value) — add a header on the outgoing leg.
  • GotoIf — branch based on a boolean condition.

Anadolu A4 trap: the SBC sent custom header names with non-ASCII Turkish characters (Genesys's X-Genesis-… family with a bogus character). PJSIP silently dropped the entire INVITE — no NOTICE, no ERROR. Header field names must be ASCII; we filter at the SBC now.

54WebSocket dial

Dial()'s WebSocket destination

The seam between Asterisk and the Freya voice agent is one line:

exten => 101,1,Dial(WebSocket/ai_media/c(ulaw)f(json))

Reading right-to-left:

  • WebSocket/ — channel driver chan_websocket.
  • ai_media — name of an entry in websocket_client.conf that defines the URI.
  • c(ulaw) — codec to use on this leg. ulaw = G.711 µ-law, 64 kbps PCM.
  • f(json) — frame format. JSON envelopes wrap each audio frame so the agent sees structured events, not just raw RTP.

This is the subject of Part 5 — everything downstream depends on this Dial line working.

55websocket_client

websocket_client.conf

[ai_media]
type=client
uri=ws://voice-agent-telephony:7860/telephony/ws
protocol=audiosocket
priority=0

voice-agent-telephony:7860 resolves to the voice-agent container (the pipecat-agent process). /telephony/ws is the agent's telephony WebSocket route. protocol=audiosocket selects the framing protocol; priority=0 is failover order if you list multiple URIs.

In our docker-compose the voice-agent runs network_mode: host, so on the KKB box the same URI resolves on the host network — no Docker DNS hops in the audio path.

56AMI vs ARI

http.conf for ARI; manager.conf for AMI

Asterisk has two control APIs. Both let an external program watch and steer calls.

  • AMI (Asterisk Manager Interface) — TCP socket on 5038, line-based protocol. Old (since 1.0), ubiquitous, every PBX vendor speaks it.
  • ARI (Asterisk REST Interface) — HTTP on 8088 with a WebSocket for events. Modern. The only sane way to write Stasis applications.
; http.conf
[general]
enabled = yes
bindaddr = 0.0.0.0
bindport = 8088

; manager.conf
[general]
enabled = yes
port = 5038

[admin]
secret=<password>
read=all
write=all

We use ARI on 8088 for Originate calls (outbound campaign dialing) and call-control during a Stasis dialog. AMI is enabled but only used by the dashboard's monitoring probe.

57stasis

ari.conf — Stasis applications

Stasis is "park the call until an external app tells me what to do". The dialplan hands the channel over; an ARI client receives a WebSocket event and decides — answer, play, transfer, originate a second leg, hang up.

; ari.conf
[freya-stasis]
type=user
read_only=no
password=...

; extensions.conf
exten => _X.,1,Stasis(freya-app)
 same => n,Hangup()

Stasis(freya-app) hands the channel to whatever ARI client is registered as freya-app. The client sees a StasisStart event, runs its routing logic, sends ARI commands, then either continues the dialplan or just hangs up.

For Freya we mostly stay in plain dialplan — Stasis is overkill when the agent itself owns conversation logic. But Stasis is the right tool when you want a thin Asterisk that just shuffles channels around at the direction of an external orchestrator.

58logger.conf

logger.conf

Verbose levels and log files. Tune this once, never look at it again.

[general]
dateformat = %F %T.%3q

[logfiles]
console => verbose,notice,warning,error
messages => notice,warning,error
queue_log => yes

asterisk -rvvv connects to the running daemon and streams the console log at verbose level 3. Add another v for level 4 (more SIP detail), another for level 5 (everything). At verbose 5 a busy box drowns you in output — keep it at 3 for live debugging and let pjsip set logger on do the SIP-message zoom-in.

59modules.conf

modules.conf — what loads, what doesn't

[modules]
autoload=yes
preload => res_odbc.so
noload => chan_sip.so
noload => chan_iax2.so

Critical to load:

  • chan_pjsip.so — the SIP channel driver.
  • chan_websocket.so — the seam to the Freya agent.
  • res_pjsip.so — PJSIP resource layer.
  • res_http_websocket.so — HTTP + WebSocket server.

noload is how you disable a module without removing it from disk. Always noload chan_sip.so — leaving it loaded alongside chan_pjsip causes the both modules try to bind 5060 race that lost two days at the first KKB deploy.

60the CLI

The Asterisk CLI

Connect to a running daemon and stream its console:

$ docker exec -it freya-asterisk asterisk -rvvv

Cheat-sheet of the commands you actually reach for:

CommandWhat it does
pjsip show endpointsList configured endpoints + their state (Online/Offline)
pjsip show contactsActive contacts (registered phones, dynamic AORs)
pjsip show channelsActive SIP dialogs only
pjsip show channelstatsPer-channel RTCP stats — jitter, packet loss
pjsip set logger onToggle full SIP message logging on the console
pjsip set logger offTurn it back off (you will want to)
pjsip reloadReload pjsip.conf without restart
core show channelsEvery active channel — SIP, WebSocket, internal
rtp set debug onShow every RTP packet in/out (verbose)
dialplan show <context>Print all extensions in a context
dialplan reloadReload extensions.conf without restart
core stop nowStop the daemon (last resort)
Demo 3 · CLI emulator
freya@kkbfcfreyasrv01 :~$ docker exec -it freya-asterisk asterisk -rvvv connected · CLI ready
Asterisk 20.5.2, Copyright (C) 1999 - 2024, Sangoma Technologies Corporation and contributors.
Connected to Asterisk 20.5.2 currently running on freya-asterisk (pid = 1)
Verbosity is at least 3
Type help to list available mock commands.
freya-asterisk*CLI>
Try: pjsip show endpoints, pjsip show channels, pjsip show channelstats, core show channels, dialplan show from-trunk, pjsip set logger on, rtp set debug on, help. Up-arrow recalls history.
Try this on KKB
$ ssh freya@192.168.35.197 "docker exec freya-asterisk asterisk -rx 'pjsip show endpoints' | head -50"
$ ssh freya@192.168.35.197 "docker exec freya-asterisk asterisk -rx 'core show channels'"

If a call is in progress you will see a WebSocket/ai_media-… channel paired with a PJSIP/… channel. That pairing is the bridge.

Checkpoint 4

A call comes in but Asterisk replies 488 Not Acceptable Here. Which two config files do you check, and what are you looking for?

Show answer

488 Not Acceptable Here means codec mismatch — the SDP offer has no codec Asterisk will accept on this endpoint. Check:

  1. pjsip.conf — the disallow= / allow= lines on the matched endpoint. Make sure the carrier's offered codec (usually ulaw or alaw; sometimes g729) is listed.
  2. extensions.conf — the Dial(WebSocket/...,c(...)) codec hint. The codec on the WebSocket leg must be in the endpoint's allow list, otherwise the bridge has no common codec.

Diagnostic: turn on pjsip set logger on, place a test call, read the m=audio line in the inbound INVITE's SDP. If it offers a codec you do not allow=, you get 488. Add the codec or push back on the carrier.