← All Tutorials

Smart Ring Group: Busy-Skip AGI for Asterisk

ViciDial Administration Intermediate 26 min read #25

Smart Ring Group: Busy-Skip AGI for Asterisk

A PHP AGI Script That Checks Extension State Before Dialing, So Only Idle Phones Ring

Difficulty: Intermediate | Time to implement: 1-2 hours | Asterisk version: 11+ (tested on 11.x, 16.x, 18.x, 20.x)


Table of Contents

  1. Introduction: The Problem with Standard Ring Groups
  2. Architecture Overview
  3. How Asterisk Device State Works
  4. The AGI Script: Complete PHP Code
  5. Asterisk Dialplan Integration
  6. Testing from the Asterisk CLI
  7. Performance Considerations
  8. Variations and Advanced Patterns
  9. Troubleshooting
  10. Complete Reference Files

1. Introduction: The Problem with Standard Ring Groups

A ring group in Asterisk dials multiple extensions simultaneously and connects the caller to whichever phone answers first. The standard Dial() command for a ring group looks like this:

Dial(SIP/1001&SIP/1002&SIP/1003&SIP/1004&SIP/1005,20,tTo)

This rings all five phones at once. If extension 1003 answers, the other four stop ringing. Simple and effective --- until your team is handling real call volume.

The problem: Asterisk sends an INVITE to every extension in the dial string, regardless of whether that phone is already on an active call. Here is what happens:

In a call center with 20-40 extensions, this is not a minor annoyance. It is a daily operational problem. During peak hours, half your extensions are on active calls at any given moment. Ringing all of them means half your INVITE requests are wasted, your agents are distracted, and the caller experience degrades.

The solution: Before building the Dial() string, check every extension's state. Only include extensions that are actually idle. If extension 1002 is INUSE, leave it out. If 1005 is UNAVAILABLE, leave it out. Dial only the phones that can actually answer.

This is what our AGI script does. It queries Asterisk's device state for each extension, builds a filtered dial string containing only available phones, and passes it back to the dialplan as a channel variable. The Dial() command then rings only idle phones.

Real-world impact:


2. Architecture Overview

Incoming Call
      |
      v
+------------------+
|   Asterisk        |
|   Dialplan        |
+--------+---------+
         |
         | AGI() call
         v
+------------------+       +-------------------+
|  check_available |       |  Asterisk Core    |
|  _extensions.php |------>|  DEVICE_STATE()   |
|                  |       |  function         |
|  For each ext:   |       +-------------------+
|  GET VARIABLE    |              |
|  DEVICE_STATE    |<-------------+
|  (SIP/10xx)      |       Returns: NOT_INUSE,
|                  |       INUSE, BUSY, etc.
+--------+---------+
         |
         | SET VARIABLE
         | AVAILABLE_EXTENSIONS
         v
+------------------+
|   Dial()         |
|   ${AVAILABLE_   |  Only idle phones ring
|   EXTENSIONS}    |
+------------------+

How the pieces fit together:

  1. An inbound call hits the Asterisk dialplan and reaches the ring group extension.
  2. The dialplan executes the AGI script (check_available_extensions.php).
  3. The AGI script loops through every extension in the group (e.g., 1031 through 1070).
  4. For each extension, it queries DEVICE_STATE(SIP/<ext>) through the AGI interface.
  5. Extensions in state NOT_INUSE (idle) are added to the dial string. Extensions in state INUSE, BUSY, or ONHOLD are skipped.
  6. The script sets the AVAILABLE_EXTENSIONS channel variable with the filtered dial string (e.g., SIP/1031&SIP/1033&SIP/1035).
  7. The dialplan calls Dial(${AVAILABLE_EXTENSIONS},10,tTo) --- only idle phones ring.
  8. If no one answers within 10 seconds, the dialplan runs the AGI again (extension states may have changed) and retries. This repeats up to 6 times (60 seconds total).

Why AGI and not a dialplan loop? You could theoretically check device state with GotoIf() and DEVICE_STATE() directly in the dialplan, but building a dynamic dial string from 40 extensions requires 40 separate GotoIf blocks, 40 string append operations, and deeply nested label jumps. The dialplan becomes unreadable and unmaintainable. An AGI script does it in a clean foreach loop.


3. How Asterisk Device State Works

Asterisk tracks the state of every device (SIP peer, PJSIP endpoint, IAX peer, etc.) in real time. You can query this state from the dialplan using the DEVICE_STATE() function:

${DEVICE_STATE(SIP/1031)}

This returns one of the following string values:

State Meaning Should We Ring It?
NOT_INUSE Registered and idle --- no active calls Yes
INUSE On an active call (answered) No
BUSY Device reports busy No
RINGING Currently ringing (not yet answered) Context-dependent
RINGINUSE Ringing while already on a call No
ONHOLD On hold No
UNAVAILABLE Not registered / unreachable Context-dependent
UNKNOWN State cannot be determined Context-dependent

Key decisions for your ring group:

How does Asterisk know the device state? For SIP (chan_sip), Asterisk tracks state based on active channels. When SIP/1031 has an active call (a channel exists), the state is INUSE. When the call ends and the channel is destroyed, the state returns to NOT_INUSE. Registration status determines UNAVAILABLE (no REGISTER received or registration expired). For PJSIP, the mechanism is similar but uses the PJSIP/ prefix:

${DEVICE_STATE(PJSIP/1031)}

Checking device state from the CLI:

# Single extension
asterisk -rx 'core show hint 1031'

# All hints (if you have hints configured)
asterisk -rx 'core show hints'

# Direct function evaluation
asterisk -rx 'dialplan eval function DEVICE_STATE SIP/1031'

The DEVICE_STATE() function works without hints configured. Hints are an additional layer used for BLF (Busy Lamp Field) on desk phones --- our AGI script queries DEVICE_STATE() directly and does not require hints.


4. The AGI Script: Complete PHP Code

4.1 Full Script

Create this file at /var/lib/asterisk/agi-bin/check_available_extensions.php:

#!/usr/bin/php -q
<?php
/**
 * check_available_extensions.php
 *
 * AGI script to build a Dial() string with available extensions only.
 * Skips extensions that are INUSE (on active call).
 * Allows extensions that are NOT_INUSE or RINGING.
 *
 * Usage in dialplan:
 *   AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
 *   Dial(${AVAILABLE_EXTENSIONS},10,tTo)
 */

// Disable output buffering — AGI requires immediate writes to STDOUT
ob_implicit_flush(true);

// ------------------------------------------------------------------
// 1. Read AGI environment variables from STDIN
// ------------------------------------------------------------------
// When Asterisk launches an AGI script, it writes environment variables
// to the script's STDIN, one per line, in the format "key: value".
// An empty line marks the end of the variables.

$agi_vars = array();
while (($line = fgets(STDIN)) !== false) {
    $line = trim($line);
    if ($line === '') break;
    if (strpos($line, ':') !== false) {
        list($key, $value) = explode(':', $line, 2);
        $agi_vars[trim($key)] = trim($value);
    }
}

// ------------------------------------------------------------------
// 2. AGI helper functions
// ------------------------------------------------------------------

/**
 * Send an AGI command to Asterisk and return the response.
 */
function agi_command($cmd) {
    fwrite(STDOUT, "$cmd\n");
    fflush(STDOUT);
    $response = fgets(STDIN);
    return trim($response);
}

/**
 * Get an Asterisk channel variable or function result.
 */
function agi_get_variable($varname) {
    $response = agi_command("GET VARIABLE $varname");
    // Response format: "200 result=1 (value)" or "200 result=0"
    if (preg_match('/result=1 \((.+)\)/', $response, $matches)) {
        return $matches[1];
    }
    return '';
}

/**
 * Set an Asterisk channel variable.
 */
function agi_set_variable($varname, $value) {
    agi_command("SET VARIABLE $varname \"$value\"");
}

/**
 * Write a verbose message visible in the Asterisk CLI.
 */
function agi_verbose($message, $level = 1) {
    agi_command("VERBOSE \"$message\" $level");
}

// ------------------------------------------------------------------
// 3. Define the extensions to check
// ------------------------------------------------------------------
// Adjust this range to match your ring group members.
// Examples:
//   range(1001, 1020)  — extensions 1001 through 1020
//   array(1001, 1005, 1010, 1015)  — specific extensions only

$extensions = range(1031, 1070);
$available = array();

agi_verbose("AGI: Checking available extensions for ring group", 1);

// ------------------------------------------------------------------
// 4. Check each extension's device state
// ------------------------------------------------------------------

foreach ($extensions as $ext) {
    $state = agi_get_variable("DEVICE_STATE(SIP/$ext)");

    // Include: NOT_INUSE (idle), RINGING (already ringing), UNAVAILABLE
    // Skip:    INUSE, BUSY, ONHOLD, RINGINUSE
    if (in_array($state, array('NOT_INUSE', 'RINGING', 'UNAVAILABLE', ''))) {
        $available[] = "SIP/$ext";
    } else {
        agi_verbose("AGI: Extension $ext state=$state - SKIPPING", 1);
    }
}

// ------------------------------------------------------------------
// 5. Build the dial string and set the channel variable
// ------------------------------------------------------------------

$dialstring = implode('&', $available);
agi_set_variable('AVAILABLE_EXTENSIONS', $dialstring);

$count = count($available);
agi_verbose("AGI: Built dial string with $count available extensions", 1);

exit(0);
?>

After creating the file, make it executable:

chmod 755 /var/lib/asterisk/agi-bin/check_available_extensions.php
chown asterisk:asterisk /var/lib/asterisk/agi-bin/check_available_extensions.php

4.2 Line-by-Line Explanation

The shebang and output buffering:

#!/usr/bin/php -q
ob_implicit_flush(true);

-q suppresses PHP's HTTP headers (which would confuse Asterisk). ob_implicit_flush(true) ensures every fwrite() reaches Asterisk immediately. Without this, PHP may buffer output and the AGI protocol stalls --- commands never reach Asterisk, and the script hangs.

Reading AGI environment variables:

$agi_vars = array();
while (($line = fgets(STDIN)) !== false) {
    $line = trim($line);
    if ($line === '') break;
    if (strpos($line, ':') !== false) {
        list($key, $value) = explode(':', $line, 2);
        $agi_vars[trim($key)] = trim($value);
    }
}

When Asterisk spawns an AGI process, it immediately writes a block of key-value pairs to the script's STDIN. These include agi_channel, agi_callerid, agi_context, and others. The block ends with a blank line. You must read and consume these variables before sending any AGI commands --- if you skip this step, your first command will collide with unread input and the protocol breaks.

We store them in $agi_vars for reference, though this script does not use them directly. A more advanced version could use $agi_vars['agi_callerid'] to make per-caller routing decisions.

The agi_command() function:

function agi_command($cmd) {
    fwrite(STDOUT, "$cmd\n");
    fflush(STDOUT);
    $response = fgets(STDIN);
    return trim($response);
}

AGI is a synchronous, line-based protocol. The script writes a command to STDOUT, and Asterisk writes the response to the script's STDIN. Every command gets exactly one response line. The response always starts with a three-digit code (200 for success, 510 for invalid command, 520 for usage error).

The agi_get_variable() function:

function agi_get_variable($varname) {
    $response = agi_command("GET VARIABLE $varname");
    if (preg_match('/result=1 \((.+)\)/', $response, $matches)) {
        return $matches[1];
    }
    return '';
}

GET VARIABLE retrieves any Asterisk channel variable or function result. When we pass DEVICE_STATE(SIP/1031), Asterisk evaluates the function and returns the result wrapped in parentheses:

200 result=1 (NOT_INUSE)

If the variable does not exist or the function fails:

200 result=0

The regex extracts the value from the parentheses.

The extension loop and state check:

$extensions = range(1031, 1070);
foreach ($extensions as $ext) {
    $state = agi_get_variable("DEVICE_STATE(SIP/$ext)");
    if (in_array($state, array('NOT_INUSE', 'RINGING', 'UNAVAILABLE', ''))) {
        $available[] = "SIP/$ext";
    } else {
        agi_verbose("AGI: Extension $ext state=$state - SKIPPING", 1);
    }
}

This is the core logic. For each extension number, we query its device state. The decision to include or exclude is a simple allow-list check:

Everything else (INUSE, BUSY, ONHOLD, RINGINUSE) is skipped. We log skipped extensions with agi_verbose() so you can see the filtering in the Asterisk CLI.

Building and setting the dial string:

$dialstring = implode('&', $available);
agi_set_variable('AVAILABLE_EXTENSIONS', $dialstring);

implode('&', ...) joins the available extensions with ampersands, producing a string like SIP/1031&SIP/1033&SIP/1035&SIP/1038. This is exactly the format Dial() expects for simultaneous ringing. The SET VARIABLE AGI command makes this available as ${AVAILABLE_EXTENSIONS} in the dialplan.


5. Asterisk Dialplan Integration

5.1 Basic Single-Attempt Pattern

The simplest usage: run the AGI once, dial the available extensions, hang up.

[ring-group-inbound]
exten => s,1,NoOp(Incoming call to ring group)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},20,tTo)
same => n,Hangup()
same => n(noavail),NoOp(No available extensions - all busy)
same => n,Playback(all-circuits-busy-now)
same => n,Hangup()

The Dial() options explained:

5.2 Multi-Attempt Retry Pattern

In production, a single attempt is rarely enough. Agents might be finishing up a call during the first ring cycle. By the second attempt (10 seconds later), they could be free. This pattern retries 6 times with a fresh device-state check before each attempt, giving the caller 60 seconds of ringing:

[ring-group-inbound]
exten => mygroup,1,NoOp(Incoming call to smart ring group)
same => n,Progress()
; Attempt 1
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 2
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 3
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 4
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 5
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 6
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; All attempts exhausted
same => n(noavail),NoOp(No available extensions after all attempts)
same => n,Hangup()

Why this works better than a single 60-second Dial():

Between each 10-second attempt, the AGI re-checks device state. If extension 1033 was on a call during attempt 1 but finished at second 12, it will be included in attempt 2 at second 10. A single 60-second Dial() with the original extension list would never pick up this change --- it would ring the same set of phones for the full minute.

Why not use a While() loop? You could write this with While() and a counter variable:

same => n,Set(ATTEMPTS=0)
same => n(loop),Set(ATTEMPTS=$[${ATTEMPTS} + 1])
same => n,GotoIf($[${ATTEMPTS} > 6]?noavail)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
same => n,Goto(loop)

Both approaches work. The flat version is more explicit and easier to read at a glance when troubleshooting at 2 AM. The loop version is more concise. Choose whichever your team prefers.

5.3 Retry with Voicemail Fallback

When all attempts are exhausted or all extensions are busy, send the caller to voicemail:

[ring-group-inbound]
exten => mygroup,1,NoOp(Incoming call to smart ring group)
same => n,Progress()
; --- 6 retry attempts (same pattern as above) ---
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Exhausted all attempts
same => n(noavail),NoOp(Sending to voicemail)
same => n,VoiceMail(1000@default,u)
same => n,Hangup()

Alternative: forward to a mobile number as a last resort:

same => n(noavail),NoOp(Forwarding to backup mobile)
same => n,Dial(SIP/trunk-provider/+44XXXXXXXXXX,30,tTo)
same => n,Hangup()

6. Testing from the Asterisk CLI

Verify the script executes

Enable AGI debugging in the Asterisk CLI:

asterisk -r
> agi set debug on

Now place a test call to the ring group. You will see the full AGI conversation in the CLI:

-- Launched AGI Script /var/lib/asterisk/agi-bin/check_available_extensions.php
    -- AGI Script check_available_extensions.php completed, returning 0
  == AGI Tx >> GET VARIABLE DEVICE_STATE(SIP/1031)
  == AGI Rx << 200 result=1 (NOT_INUSE)
  == AGI Tx >> GET VARIABLE DEVICE_STATE(SIP/1032)
  == AGI Rx << 200 result=1 (INUSE)
    -- AGI: Extension 1032 state=INUSE - SKIPPING
  == AGI Tx >> GET VARIABLE DEVICE_STATE(SIP/1033)
  == AGI Rx << 200 result=1 (NOT_INUSE)
  ...
    -- AGI: Built dial string with 28 available extensions

Manually check device states

# Check a specific extension's state
asterisk -rx 'dialplan eval function DEVICE_STATE SIP/1031'

# List all active SIP channels (shows who is on a call)
asterisk -rx 'core show channels' | grep SIP/

# Count active channels
asterisk -rx 'core show channels count'

Test the AGI script standalone

You can run the AGI script manually from the command line. It will not have a real Asterisk channel, so it will hang waiting for STDIN, but you can simulate the AGI protocol:

# Feed it mock AGI variables and see what it does
echo -e "agi_channel: SIP/1031-00000001\nagi_callerid: 1234567890\n" | \
  /var/lib/asterisk/agi-bin/check_available_extensions.php

This will fail on the GET VARIABLE commands (no real Asterisk channel), but it verifies the script parses the initial AGI variables correctly without syntax errors.

Verify the dial string

Add a temporary NoOp after the AGI call to log the resulting variable:

same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,NoOp(DIAL STRING: ${AVAILABLE_EXTENSIONS})
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)

In the CLI, you will see:

-- Executing NoOp("SIP/trunk-00001", "DIAL STRING: SIP/1031&SIP/1033&SIP/1035&SIP/1037") in new stack

7. Performance Considerations

AGI process spawning overhead

Each AGI invocation spawns a new PHP process. On a modern server, PHP startup takes 10-30ms. The script then executes 40 GET VARIABLE commands (one per extension), each taking approximately 1-2ms over the local AGI socket. Total AGI execution time for 40 extensions: 50-100ms.

For a ring group that retries 6 times, that is 6 AGI invocations, approximately 300-600ms of total overhead across the entire 60-second ring cycle. This is negligible.

Scaling to larger extension pools

Extensions Approx. AGI Time Practical?
10 20-40ms Excellent
40 50-100ms Excellent
100 120-220ms Good
200 230-430ms Acceptable
500+ 500ms+ Consider FastAGI

If you have more than 200 extensions in a single ring group, consider these optimizations:

FastAGI (persistent socket connection): Instead of spawning a new PHP process for every call, run a persistent FastAGI server that Asterisk connects to over TCP. This eliminates the process-spawn overhead:

; Instead of spawning a new process:
; AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)

; Connect to a persistent FastAGI server:
AGI(agi://127.0.0.1:4573/check_available_extensions)

FastAGI eliminates ~20ms of PHP startup overhead per invocation, but the real benefit is at scale when handling hundreds of concurrent calls.

PJSIP with AOR/contact query: If you are on PJSIP (Asterisk 13+), you can query PJSIP_AOR() for contact count, which is faster than individual device state checks for large pools.

Memory and concurrency

Each AGI process uses approximately 5-8MB of memory (PHP interpreter + script). With 50 concurrent calls each running the AGI script, that is 250-400MB. If your server has 4GB+ of RAM (typical for Asterisk), this is well within safe limits.

Asterisk limits concurrent AGI processes via the maxfiles ulimit. Ensure your Asterisk process has sufficient file descriptors:

# Check current limits
cat /proc/$(pidof asterisk)/limits | grep "Max open files"

# If needed, add to /etc/security/limits.conf:
asterisk soft nofile 65536
asterisk hard nofile 65536

8. Variations and Advanced Patterns

8.1 Priority-Based Ring Order

Instead of ringing all available extensions simultaneously, ring them in priority tiers. Tier 1 (senior agents) get first crack. If none answer, tier 2 (mid-level) get the call. Then tier 3 (trainees).

Modify the AGI script to accept a tier parameter:

#!/usr/bin/php -q
<?php
ob_implicit_flush(true);

// Read AGI vars
$agi_vars = array();
while (($line = fgets(STDIN)) !== false) {
    $line = trim($line);
    if ($line === '') break;
    if (strpos($line, ':') !== false) {
        list($key, $value) = explode(':', $line, 2);
        $agi_vars[trim($key)] = trim($value);
    }
}

function agi_command($cmd) {
    fwrite(STDOUT, "$cmd\n");
    fflush(STDOUT);
    return trim(fgets(STDIN));
}

function agi_get_variable($varname) {
    $response = agi_command("GET VARIABLE $varname");
    if (preg_match('/result=1 \((.+)\)/', $response, $matches)) {
        return $matches[1];
    }
    return '';
}

function agi_set_variable($varname, $value) {
    agi_command("SET VARIABLE $varname \"$value\"");
}

function agi_verbose($message, $level = 1) {
    agi_command("VERBOSE \"$message\" $level");
}

// Define priority tiers
$tiers = array(
    1 => array(1031, 1032, 1033, 1034, 1035),  // Senior agents
    2 => array(1036, 1037, 1038, 1039, 1040),  // Mid-level
    3 => array(1041, 1042, 1043, 1044, 1045),  // Junior
);

// Get requested tier from channel variable (default: all)
$requested_tier = agi_get_variable('RING_TIER');
if ($requested_tier === '' || !isset($tiers[(int)$requested_tier])) {
    // No tier specified — check all extensions
    $extensions = array();
    foreach ($tiers as $t) {
        $extensions = array_merge($extensions, $t);
    }
} else {
    $extensions = $tiers[(int)$requested_tier];
}

$available = array();
foreach ($extensions as $ext) {
    $state = agi_get_variable("DEVICE_STATE(SIP/$ext)");
    if (in_array($state, array('NOT_INUSE', 'RINGING', 'UNAVAILABLE', ''))) {
        $available[] = "SIP/$ext";
    }
}

agi_set_variable('AVAILABLE_EXTENSIONS', implode('&', $available));
agi_verbose("AGI: Tier " . ($requested_tier ?: 'ALL') . " - " . count($available) . " available", 1);

exit(0);
?>

Dialplan for tiered ringing:

[ring-group-tiered]
exten => mygroup,1,NoOp(Tiered ring group)
same => n,Progress()
; Tier 1 — Senior agents (15 seconds)
same => n,Set(RING_TIER=1)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_tiered.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?tier2)
same => n,Dial(${AVAILABLE_EXTENSIONS},15,tTo)
; Tier 2 — Mid-level (15 seconds)
same => n(tier2),Set(RING_TIER=2)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_tiered.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?tier3)
same => n,Dial(${AVAILABLE_EXTENSIONS},15,tTo)
; Tier 3 — Junior (15 seconds)
same => n(tier3),Set(RING_TIER=3)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_tiered.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?allbusy)
same => n,Dial(${AVAILABLE_EXTENSIONS},15,tTo)
; Everyone tried, ring ALL available as last resort
same => n(allbusy),Set(RING_TIER=)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_tiered.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},30,tTo)
same => n(noavail),Playback(all-circuits-busy-now)
same => n,Hangup()

8.2 Weighted Random Selection

For load balancing across a large team, you may want to randomly select a subset of available extensions rather than ringing all of them at once. This reduces the number of simultaneous SIP INVITEs and distributes calls more evenly:

// After building the $available array, randomly select up to 5
if (count($available) > 5) {
    shuffle($available);
    $available = array_slice($available, 0, 5);
}

8.3 Database-Driven Extension Lists

Instead of hardcoding extension ranges in the script, load them from a database. This lets you manage ring group membership through a web interface or admin panel:

// Connect to your database
$db = new mysqli('127.0.0.1', 'ring_user', 'YOUR_DB_PASSWORD', 'pbx_config');
if ($db->connect_error) {
    agi_verbose("AGI: Database connection failed", 1);
    exit(1);
}

// Get the ring group name from a channel variable
$group_name = agi_get_variable('RING_GROUP_NAME');
$group_name = $db->real_escape_string($group_name);

// Query ring group members
$result = $db->query(
    "SELECT extension FROM ring_group_members
     WHERE group_name = '$group_name' AND active = 1
     ORDER BY priority ASC"
);

$extensions = array();
while ($row = $result->fetch_assoc()) {
    $extensions[] = $row['extension'];
}
$db->close();

// Now check device state for each extension (same loop as before)

Schema for the ring_group_members table:

CREATE TABLE ring_group_members (
    id INT AUTO_INCREMENT PRIMARY KEY,
    group_name VARCHAR(50) NOT NULL,
    extension VARCHAR(10) NOT NULL,
    priority INT DEFAULT 5,
    active TINYINT DEFAULT 1,
    UNIQUE KEY (group_name, extension)
);

-- Example data
INSERT INTO ring_group_members (group_name, extension, priority) VALUES
('sales', '1031', 1),
('sales', '1032', 1),
('sales', '1033', 2),
('support', '1041', 1),
('support', '1042', 1);

8.4 Hunt Group (Sequential)

A hunt group dials extensions one at a time instead of simultaneously. The AGI script can output a comma-separated list instead of ampersand-separated, and the dialplan dials them sequentially:

// In the AGI script — set individual variables instead of a dial string
$i = 1;
foreach ($available as $ext) {
    agi_set_variable("HUNT_EXT_$i", $ext);
    $i++;
}
agi_set_variable('HUNT_COUNT', count($available));

Dialplan for hunt group:

[hunt-group]
exten => s,1,NoOp(Hunt group - sequential dialing)
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_hunt.php)
same => n,Set(i=1)
same => n(huntloop),GotoIf($[${i} > ${HUNT_COUNT}]?noavail)
same => n,Dial(${HUNT_EXT_${i}},8,tTo)
same => n,Set(i=$[${i} + 1])
same => n,Goto(huntloop)
same => n(noavail),Playback(all-circuits-busy-now)
same => n,Hangup()

This rings extension 1 for 8 seconds, then extension 2 for 8 seconds, and so on --- but only extensions that were idle when the AGI ran.


9. Troubleshooting

AGI script does not execute

Symptom: Dialplan proceeds but AVAILABLE_EXTENSIONS is empty or never set.

Check these in order:

# 1. File exists and is executable
ls -la /var/lib/asterisk/agi-bin/check_available_extensions.php
# Should show: -rwxr-xr-x asterisk asterisk

# 2. PHP is installed and the path is correct
which php
# Should match the shebang: /usr/bin/php

# 3. No syntax errors
php -l /var/lib/asterisk/agi-bin/check_available_extensions.php
# Should output: No syntax errors detected

# 4. SELinux is not blocking execution (CentOS/RHEL)
getenforce
# If "Enforcing", check audit log:
grep agi /var/log/audit/audit.log | tail -5

All extensions show as UNAVAILABLE

Symptom: The AGI reports all extensions as UNAVAILABLE even though phones are registered.

Cause: You are using PJSIP but querying SIP/ device state (or vice versa).

# Check which SIP module is loaded
asterisk -rx 'module show like sip'
# Look for chan_sip.so or chan_pjsip.so

If using PJSIP, change the script:

// Change this:
$state = agi_get_variable("DEVICE_STATE(SIP/$ext)");
$available[] = "SIP/$ext";

// To this:
$state = agi_get_variable("DEVICE_STATE(PJSIP/$ext)");
$available[] = "PJSIP/$ext";

AGI hangs or times out

Symptom: The call pauses for the full AGI timeout (usually 30 seconds) before continuing.

Causes:

  1. Missing ob_implicit_flush(true) --- PHP buffers output, AGI commands never reach Asterisk.
  2. Not reading the initial AGI variables --- The first GET VARIABLE command collides with unread input.
  3. PHP error output going to STDOUT --- If PHP prints a warning, it gets interpreted as an AGI command response, corrupting the protocol.

Fix #3 by suppressing error output:

// Add at the top of the script, after the shebang
error_reporting(0);
ini_set('display_errors', 0);

Or redirect errors to a log:

ini_set('log_errors', 1);
ini_set('error_log', '/var/log/asterisk/agi-errors.log');

Extensions ring even though they are on a call

Symptom: The AGI reports an extension as NOT_INUSE but the agent is actually on a call.

Cause: The extension has call-limit set higher than 1, or busylevel is not configured. Asterisk only reports INUSE if the number of active calls meets or exceeds the SIP peer's call-limit or busylevel:

; In sip.conf or sip_additional.conf
[1031]
type=friend
busylevel=1       ; Report BUSY after 1 active call
call-limit=2      ; Allow up to 2 simultaneous calls

With call-limit=2 and busylevel=1, the extension will show INUSE after the first call (what we want). Without busylevel, it only shows INUSE when both channels are active.

For PJSIP:

; In pjsip.conf
[1031]
type=endpoint
device_state_busy_at=1   ; Report INUSE after 1 active call

Performance degrades with many concurrent calls

Symptom: AGI execution takes longer than 500ms, causing noticeable delay before ringing starts.

Solutions:

  1. Reduce the extension range. If your ring group has 40 members but only 15 are logged in at any time, query only registered extensions.
  2. Switch to FastAGI (see Section 7).
  3. Cache device states. If you run the AGI 6 times in 60 seconds for the same call, the first invocation could cache states in a shared memory file that subsequent invocations read (with a 5-second TTL).

10. Complete Reference Files

File: /var/lib/asterisk/agi-bin/check_available_extensions.php

The complete script as shown in Section 4.1. Ensure:

File: /etc/asterisk/extensions_custom.conf (or customexte.conf)

; =============================================================
; Smart Ring Group — Busy-Skip with Multi-Attempt Retry
; =============================================================
; Rings only idle extensions. Re-checks state between attempts.
; Total ring time: 6 attempts x 10 seconds = 60 seconds.
;
; Adjust the extension name (mygroup) and attempt count as needed.
; =============================================================

[ring-group-smart]
exten => mygroup,1,NoOp(Smart ring group - busy-skip AGI)
same => n,Progress()
; Attempt 1
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 2
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 3
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 4
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 5
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; Attempt 6
same => n,AGI(/var/lib/asterisk/agi-bin/check_available_extensions.php)
same => n,GotoIf($["${AVAILABLE_EXTENSIONS}" = ""]?noavail)
same => n,Dial(${AVAILABLE_EXTENSIONS},10,tTo)
; All attempts exhausted
same => n(noavail),NoOp(No available extensions after all attempts)
same => n,Hangup()

Quick Deployment Checklist

[ ] PHP installed: which php → /usr/bin/php
[ ] Script created at /var/lib/asterisk/agi-bin/check_available_extensions.php
[ ] Permissions set: chmod 755, chown asterisk:asterisk
[ ] Extension range updated in the script to match your ring group
[ ] SIP prefix matches your channel driver (SIP/ vs PJSIP/)
[ ] busylevel=1 set on all SIP peers in the ring group
[ ] Dialplan context added to extensions_custom.conf (or customexte.conf)
[ ] Dialplan reloaded: asterisk -rx 'dialplan reload'
[ ] AGI debug enabled for testing: agi set debug on
[ ] Test call placed — verified INUSE extensions are skipped in CLI
[ ] Test call placed with all extensions busy — verified noavail path
[ ] AGI debug disabled after testing: agi set debug off

License: This tutorial is provided for educational and commercial use. Adapt the code to your environment. The AGI protocol, device state functions, and dialplan patterns described here are standard Asterisk features available in all versions from 11.x through the current release.

Need expert help with your setup?

VoIP infrastructure consulting, AI voice agent integration, monitoring stacks, scaling — I've done it all in production.

Get a Free Consultation