ruby-asterisk

Maintainability

A Ruby client for Asterisk PBX covering three interfaces:

Interface Use case Entry point Concurrency model
AMI TCP command/event channel RubyAsterisk::AMI::Client Threads + Promise (non-blocking)
ARI HTTP REST control API RubyAsterisk::ARI::Client Synchronous (Faraday)
ARI WebSocket Real-time event stream RubyAsterisk::ARI::WebSocket websocket-driver + Threads
AGI / FastAGI In-process dialplan logic RubyAsterisk::AGI::Server Async + Fibers

Requirements

  • Ruby ≥ 3.1
  • Runtime dependencies: async ~> 2.0, faraday >= 1.0, websocket-driver ~> 0.8

Ruby 3.1 note: the IO#timeout shim required by the Async Fiber scheduler is applied automatically when the AGI module is loaded — no action needed.

Installation

gem 'ruby-asterisk'
bundle install

AMI — Asterisk Manager Interface

Initialize and connect

require 'ruby-asterisk'

ami = RubyAsterisk::AMI::Client.new(host: '192.168.1.1', port: 5038)
ami.connect

The Promise model

Every AMI command returns a RubyAsterisk::AMI::Promise immediately — it does not block. Call .value(timeout) to wait for the Response:

promise  = ami.ping          # returns immediately
response = promise.value     # blocks up to 5 s (default)
response = promise.value(10) # custom timeout in seconds

puts response.success        # => true
puts response.message        # => "Pong"
puts response.action_id      # => "lk3m0p..."
puts response.data           # => structured Hash or Array depending on command
puts response.raw_response   # => raw AMI text

# On timeout:
begin
  response = promise.value(2)
rescue Timeout::Error => e
  puts e.message
end

disconnect rejects all pending promises with a RuntimeError.

Login / Logoff

ami.(username: 'mark', secret: 'mysecret').value
ami.logoff.value

System commands

ami.ping.value
ami.command('core show channels').value
ami.wait_event(timeout: -1).value     # -1 = wait forever: .value blocks until an event arrives
ami.wait_event(timeout: 30).value     # bounded wait; .value gives up after 30 s
ami.event_mask('on').value            # enable event delivery on this connection
ami.parked_calls.value
ami.device_state_list.value
ami.skinny_devices.value
ami.skinny_lines.value

Channel commands

ami.core_show_channels.value
ami.status.value                      # all channels
ami.status(channel: 'SIP/alice-001').value

# Originate: channel, context, callee, priority
ami.originate('SIP/9100', 'OUTGOING', '123456', '1',
              caller_id: 'Alice',
              variable: 'var1=12,var2=99',
              timeout: 30_000,
              async: true).value

# Originate to application
ami.originate_app(channel: 'SIP/9100',
                  application: 'Playback',
                  data: 'hello-world',
                  async: true).value

ami.redirect('SIP/alice-001', 'default', '100', '1').value
ami.hangup('SIP/alice-001').value
ami.atxfer(channel: 'SIP/alice-001',
           exten: '200',
           context: 'default',
           priority: '1').value

Queue commands

ami.queues.value
ami.queue_status.value
ami.queue_summary('support').value
ami.queue_add('support', 'SIP/agent1', penalty: 1, member_name: 'Alice').value
ami.queue_remove(queue: 'support', interface: 'SIP/agent1').value
ami.queue_pause(interface: 'SIP/agent1',
                paused: true,
                queue: 'support',
                reason: 'lunch').value

Conference commands

ami.meet_me_list.value
ami.confbridges.value                          # list all ConfBridge rooms
ami.confbridge('my-room').value                # list participants in a room
ami.confbridge_mute(conference: 'my-room',   channel: 'SIP/alice-001').value
ami.confbridge_unmute(conference: 'my-room', channel: 'SIP/alice-001').value
ami.confbridge_kick(conference: 'my-room',   channel: 'SIP/alice-001').value

Mailbox commands

ami.mailbox_status(mailbox: '1001', context: 'default').value
ami.mailbox_count(mailbox: '1001', context: 'default').value

SIP commands

ami.sip_peers.value
ami.sip_show_peer('alice').value
ami.sip_show_registry.value

Extension state

response = ami.extension_state(exten: '1001', context: 'default').value
puts response.data[:hints]
# => [{ "Exten" => "1001", "Status" => "1",
#        "DescriptiveStatus" => "In Use", ... }]

Status values from RubyAsterisk::DESCRIPTIVE_STATUS: -1 Extension not found · 0 Idle · 1 In Use · 2 Busy · 3 Unavailable · 4 Ringing · 5 On Hold

DescriptiveStatus is only added for statuses in that map; any other value yields nil.

Monitor commands

ami.monitor('SIP/alice-001', mix: true, file: '/tmp/call', format: 'wav').value
ami.pause_monitor('SIP/alice-001').value
ami.unpause_monitor('SIP/alice-001').value
ami.change_monitor(channel: 'SIP/alice-001', file: '/tmp/call2').value
ami.stop_monitor('SIP/alice-001').value

Disconnect

ami.disconnect

The Response object

Attribute Type Description
success Boolean true when raw response contains Response: Success
action_id String ActionID echoed by Asterisk
message String Message: field from the response
data Hash / Array Structured data parsed by ResponseParser (see PARSE_DATA)
raw_response String/Array The unprocessed AMI text
type String The AMI action name

Commands with structured data parsing (PARSE_DATA): CoreShowChannels, ParkedCalls, Originate, MeetMeList, ConfbridgeListRooms, ConfbridgeList, Status, ExtensionState, DeviceStateList, SKINNYdevices, SKINNYlines, QueuePause, Pong, Events, SIPpeers, SIPshowpeer.


ARI — Asterisk REST Interface

Initialize

require 'ruby-asterisk'

client = RubyAsterisk::ARI::Client.new(
  'http://192.168.1.1:8088',  # ARI base URL
  'my_api_key',               # API key (username; password is empty)
  'my_stasis_app'             # Stasis application name
)

Asterisk info

info = client.asterisk_info
puts info['system']['version']

Channels

channel = client.channels.get('1553112062.1')
puts channel.id             # => "1553112062.1"
puts channel.data['name']   # => "PJSIP/alice-00000001"

channels = client.channels.list
channels.each { |ch| puts ch.data['name'] }

channel.ring
channel.answer
channel.play('sound:hello-world')
channel.play('sound:tt-monkeys', playbackId: 'pb-1')
channel.hangup

Bridges

bridges = client.bridges.list
bridge  = client.bridges.get('my-bridge-id')

bridge.add_channel('1553112062.1')
bridge.remove_channel('1553112062.1')
bridge.destroy

Playbacks

playback = client.playbacks.get('pb-1')
playback.control('pause')
playback.control('unpause')
playback.control('restart')
playback.stop

Endpoints

endpoints = client.endpoints.list
endpoints.each do |ep|
  puts "#{ep.technology}/#{ep.resource}#{ep.state}"
end

Error handling

All ARI methods raise RubyAsterisk::Error on HTTP 4xx/5xx:

begin
  channel = client.channels.get('nonexistent-id')
rescue RubyAsterisk::Error => e
  puts e.message  # => "Channel not found"
end

ARI WebSocket — Real-time events

Initialize and connect

require 'ruby-asterisk'

ws = RubyAsterisk::ARI::WebSocket.new(
  'http://192.168.1.1:8088',
  'my_api_key',
  'my_stasis_app',
  auto_reconnect: true,    # default: true
  reconnect_delay: 5,      # seconds between attempts, default: 5
  ping_interval: 30,       # keep-alive ping interval, default: 30
  logger: Logger.new($stdout)
)

Register event handlers

ws.on('StasisStart') do |event|
  puts "Call arrived on channel #{event['channel']['id']}"
end

ws.on('ChannelStateChange') do |event|
  puts "Channel state: #{event['channel']['state']}"
end

ws.on('StasisEnd') do |event|
  puts "Call ended: #{event['channel']['id']}"
end

Multiple handlers for the same event type are supported.

Connect and disconnect

ws.connect do |sock|
  puts "WebSocket connected"
end

# Check status
ws.connected?   # => true / false

# Send a message (returns false if not connected)
ws.send_message?({ type: 'ping' })

# Disconnect (disables auto-reconnect)
ws.disconnect

Threading note: connect starts a background connection thread that owns the socket read loop. Event callbacks execute in that thread — avoid direct UI updates or non-thread-safe operations inside callbacks.


AGI — FastAGI server

What is FastAGI?

FastAGI lets you run the AGI dialplan logic in an external process that Asterisk connects to via TCP. ruby-asterisk provides a server that handles each Asterisk connection in its own Fiber under the async gem, so many concurrent calls share a single OS thread.

In Asterisk's extensions.conf:

exten => 100,1,AGI(agi://192.168.1.100:4573)

Initialize and start

require 'ruby-asterisk'

server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573, logger: Logger.new($stdout))
server.handle do |session|
  session.answer
  session.stream_file('hello-world')
  session.hangup
end
server.run   # blocks until server.stop is called

The handler block must be registered with handle before calling run, otherwise RubyAsterisk::Error is raised.

Ephemeral port

Pass port: 0 to bind to any free port; the actual bound port is available in server.port after run starts.

Stop

# In a signal handler or another thread:
server.stop

Active sessions finish naturally after stop is called.

The Session object

Inside the handler block, session is a RubyAsterisk::AGI::Session. All command methods return a Hash: { code: Integer, result: String|nil, data: String|nil }.

Environment

session.env
# => { "agi_network" => "yes", "agi_channel" => "SIP/alice-001",
#      "agi_callerid" => "0391234567", ... }

Call control

session.answer
session.hangup
session.hangup('SIP/alice-001')   # explicit channel
session.channel_status            # status of current channel
session.channel_status('SIP/b')   # explicit channel

Audio playback

session.stream_file('hello-world')          # play; no escape digits
session.stream_file('hello-world', '1234')  # play; stop on digit 1/2/3/4
session.say_digits('12345')
session.say_digits('12345', '#')            # stop on #
session.say_number(42)
session.say_number(42, '#*')

DTMF and input

result = session.get_data('enter-pin', timeout: 5000, max_digits: 4)
# Like every verb, returns the response Hash; the key's ASCII value
# (or -1 on timeout) is in response[:result].
response = session.wait_for_digit(5000)
digit    = response[:result]

Variables

session.set_variable('MY_VAR', 'hello')
response = session.get_variable('MY_VAR')
puts response[:data]   # => "hello"

Recording

session.record_file('/tmp/greeting',
                    format: 'wav',
                    escape_digits: '#',
                    timeout_ms: 10_000,
                    beep: true,
                    silence: 3)

Execute dialplan application

session.exec('Playback', 'hello-world')
session.dial('PJSIP/bob', timeout: 30, options: 'r')

AstDB

session.database_put('myapp', 'last_caller', '0391234567')
response = session.database_get('myapp', 'last_caller')
puts response[:data]   # => "0391234567"
session.database_del('myapp', 'last_caller')
session.database_deltree('myapp')

Logging and text

session.verbose('Starting IVR flow', level: 2)
session.send_text('Hello from AGI')
session.send_image('logo.png')

IVR example — DTMF menu

server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
server.handle do |session|
  session.answer

  result  = session.get_data('ivr-menu', timeout: 5000, max_digits: 1)
  choice  = result[:data].to_s.strip

  case choice
  when '1'
    session.dial('PJSIP/sales', timeout: 30)
  when '2'
    session.dial('PJSIP/support', timeout: 30)
  else
    session.stream_file('invalid-option')
  end

  session.hangup
end
server.run

Recording example — with error handling

server = RubyAsterisk::AGI::Server.new('0.0.0.0', 4573)
server.handle do |session|
  session.answer
  session.stream_file('beep')

  begin
    session.record_file('/tmp/message', format: 'wav',
                        escape_digits: '#', timeout_ms: 30_000,
                        beep: false, silence: 5)
    session.database_put('voicemail', 'last_recording', '/tmp/message')
  rescue RubyAsterisk::Error => e
    # e.message includes the 520 multi-line error body
    session.verbose("Recording failed: #{e.message}", level: 1)
  end

  session.hangup
end
server.run

Error handling in sessions

session.execute (the underlying method) raises RubyAsterisk::Error:

  • On EOF: "Connection closed by Asterisk"
  • On AGI 5xx responses, including multi-line 520-…/520 End of proper usage. errors

All command methods (answer, stream_file, etc.) are thin wrappers around execute and propagate the same exception.


LLM skill files

Detailed how-to guides for each subsystem are in .claude/skills/. Claude Code loads them automatically as project-level skills; other agent runners can read them as markdown prompts.

Skill file Covers
.claude/skills/ruby-asterisk-ami/SKILL.md AMI async client, Promise model, all command groups
.claude/skills/ruby-asterisk-ari/SKILL.md ARI HTTP client, resources, error handling
.claude/skills/ruby-asterisk-ari-websocket/SKILL.md ARI WebSocket events, callbacks, reconnect
.claude/skills/ruby-asterisk-agi/SKILL.md FastAGI server, Session verbs, IVR and recording patterns

Development

bundle install
bundle exec rake spec     # run all tests
bundle exec rubocop       # code quality checks

Test infrastructure: spec/support/mock_ami_server.rb provides a mock AMI server for unit tests.

Questions or issues? Please use the issue tracker. Contributions via fork + pull request are welcome.

This gem was created by Emiliano Della Casa and is licensed under the MIT License, distributed by courtesy of Engim srl.