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. Tieschan_pjsipto the configuration tree.res_http_websocket.so— server-side WebSocket support inside Asterisk.app_dial.so— theDial()dialplan application.app_mixmonitor.so— call recording (mixed user + agent into a single WAV).chan_sip.so— deprecated. Wenoloadit so it cannot conflict on UDP 5060.
$ docker exec freya-asterisk asterisk -rx "module show like chan"
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 frompjsip.confascontext = from-trunk.exten => _+90X.,1,...— extension pattern. Leading_marks it as a pattern.1is the priority.same => n,...— continue at the next priority on the same extension. Thenis literally "next".- Applications —
NoOp,Set,MixMonitor,Dial,Hangup. Each one mutates the channel.
[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
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 ofVAR.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.
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.
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.
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:
Listening sockets
UDP, TCP, TLS, or WSS bind addresses. bind=0.0.0.0:5060. Where Asterisk accepts SIP.
A peer (UA)
Codec list, dialplan context, NAT settings, language. The bulk of your config lives here.
Address-of-Record
Where the contact lives. Static contact=sip:host:port for outgoing trunks; dynamic for registered phones.
Digest credentials
Username + password for SIP digest auth. Skipped for IP-only trunks.
IP-based match
Map a source IP (CIDR) to an endpoint. Used when the trunk has no auth and no AOR registers.
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.
[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.
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.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.
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.
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
Transports: UDP, TCP, TLS, WS, WSS
SIP is transport-agnostic. PJSIP supports five transports, each with its default port and typical use case.
[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.
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.
- 102 —
Echo(). 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.
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.
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 driverchan_websocket.ai_media— name of an entry inwebsocket_client.confthat 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.
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.
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.
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.
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.
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.
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:
| Command | What it does |
|---|---|
pjsip show endpoints | List configured endpoints + their state (Online/Offline) |
pjsip show contacts | Active contacts (registered phones, dynamic AORs) |
pjsip show channels | Active SIP dialogs only |
pjsip show channelstats | Per-channel RTCP stats — jitter, packet loss |
pjsip set logger on | Toggle full SIP message logging on the console |
pjsip set logger off | Turn it back off (you will want to) |
pjsip reload | Reload pjsip.conf without restart |
core show channels | Every active channel — SIP, WebSocket, internal |
rtp set debug on | Show every RTP packet in/out (verbose) |
dialplan show <context> | Print all extensions in a context |
dialplan reload | Reload extensions.conf without restart |
core stop now | Stop the daemon (last resort) |
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.
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.$ 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.
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:
pjsip.conf— thedisallow=/allow=lines on the matched endpoint. Make sure the carrier's offered codec (usuallyulaworalaw; sometimesg729) is listed.extensions.conf— theDial(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.