Skip to main content

ARI apps

The Asterisk REST Interface (ARI) is a low-level API that lets external applications take direct control of Asterisk channels, bridges, and media. Instead of configuring dialplan logic inside PBXware, you build a standalone application that reacts to real-time call events and drives call flow programmatically - giving you full flexibility over routing, media mixing, and call state.


What is ARI

ARI exposes two communication channels to your application:

  • REST API - create and manipulate Asterisk objects (channels, bridges, playbacks, recordings) by issuing HTTP requests.
  • WebSocket event stream - receive real-time events (channel state changes, DTMF, media end, hangup) as JSON messages over a persistent WebSocket connection.

Together they form a request/event loop: your application subscribes to events over the WebSocket, and responds to them by calling REST endpoints to move channels, play audio, join bridges, or hang up.

ARI communication diagram

When a call enters a Stasis application (a special dialplan entry point that hands control to ARI), Asterisk pauses normal dialplan execution and waits for your application to drive the call. Your application is fully in control until it releases the channel or the call ends.

Key concepts

ConceptDescription
ChannelA single call leg - an inbound or outbound SIP/PJSIP call
BridgeA mixing container that connects one or more channels so they can hear each other
Stasis appA named entry point that transfers call control from dialplan to your ARI application
PlaybackA media operation that plays audio into a channel or bridge
RecordingA media operation that captures audio from a channel or bridge

Building a simple application

This section walks through a sample ARI application that demonstrates three patterns you will use in almost every real-world integration: identifying the caller, collecting multi-step input, and routing the call to the right destination. Use it as a reference while building your own application - each step explains not just what the code does, but why it is structured that way and what alternatives exist.

The code examples use Go. The ARI events and REST endpoints are the same regardless of language; only the HTTP and WebSocket libraries differ.


What it does

When a call arrives the application works through the following flow:

  1. Answer the inbound channel.
  2. Identify the caller - look up the caller ID in a database. If the caller is a VIP, play a personalised greeting and connect them directly to their account manager.
  3. Collect input - for regular callers, play a prompt and collect DTMF digits terminated by #. Each digit resets an inactivity timer; if the caller stops entering digits before pressing #, the call is hung up after input_timeout seconds.
  4. Route the call - immediately look up the entered ID when # is pressed. If found, play a confirmation tone and bridge the caller to that customer's destination; if not found, play an error tone and fall back to a default destination. Either side hanging up cleans up the other leg and destroys the bridge automatically.

Prerequisites

  • Any language that supports HTTP requests and WebSocket connections

  • ARI enabled on PBXware

  • ARI credentials (username / password) and a Stasis application name configured in PBXware

  • A config.json file in the working directory (or environment variables as a fallback - see Run the application)

    {
    "ari": {
    "host": "your-pbxware-host",
    "username": "your-ari-username",
    "password": "your-ari-password",
    "app": "your-app-name"
    },
    "db": "user:pass@tcp(host:3306)/dbname",
    "default_destination": "PJSIP/1000",
    "ring_timeout": 30,
    "input_timeout": 30,
    "max_digits": 20
    }
    FieldDefaultDescription
    default_destinationPJSIP/1000Fallback endpoint when no customer record matches the entered ID
    ring_timeout30Seconds to wait for the outbound leg to answer before giving up
    input_timeout30Seconds of DTMF inactivity before the call is hung up
    max_digits20Maximum digits a caller can enter before #
  • A database with the two tables below

CREATE TABLE vip_callers (
caller_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128),
account_manager_ext VARCHAR(64)
);

CREATE TABLE customers (
customer_id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128),
destination_ext VARCHAR(64)
);

Discovering what ARI can do

Before writing any code it is worth knowing how to browse the full ARI surface. Your PBXware instance exposes a live API reference at:

http://yourpbxwaredomain/ari/api-docs

This lists every REST resource (channels, bridges, recordings, sounds, endpoints, and more) along with their parameters and response schemas. It is the authoritative source for what operations are available - consult it when you need something beyond what this guide covers.

On the event side, every JSON message arriving over the WebSocket has a type field. The most relevant events are:

EventWhen it fires
StasisStartA call enters your Stasis application
StasisEndA call leaves your application (hung up or transferred out)
ChannelDtmfReceivedThe caller pressed a key
PlaybackStartedA media playback began
PlaybackFinishedA media playback completed
ChannelHangupRequestThe caller hung up
ChannelStateChangeA channel's state changed (ringing, up, etc.)

The full event catalogue is in the API docs under the events resource. During development, logging every incoming event type is a quick way to observe what Asterisk sends and when.


Step 1 - Connect to ARI

Open a WebSocket connection to the ARI events endpoint. Pass your application name as the app query parameter and your credentials in an Authorization: Basic header - keeping them out of URLs prevents them from appearing in logs or proxy traces.

wsURL := url.URL{
Scheme: "ws",
Host: cfg.ARI.Host,
Path: "/ari/events",
RawQuery: url.Values{"app": {cfg.ARI.App}}.Encode(),
}
authHeader := http.Header{
"Authorization": {"Basic " + base64.StdEncoding.EncodeToString(
[]byte(cfg.ARI.Username + ":" + cfg.ARI.Password),
)},
}

conn, _, err := websocket.DefaultDialer.Dial(wsURL.String(), authHeader)

The app value must match the Stasis application name configured in the PBXware dialplan. This is how Asterisk knows which calls to hand to your application - only calls that pass through a Stasis(<app-name>) dialplan instruction will produce StasisStart events for your app.

Read messages in a loop and dispatch each event to your handler:

for {
_, msg, err := conn.ReadMessage()
// ...
var event ARIEvent
json.Unmarshal(msg, &event)
app.HandleEvent(event)
}

Events are dispatched synchronously rather than in separate goroutines. ARI delivers events for a channel serially, so synchronous dispatch preserves that ordering without coordination overhead. Spawning a goroutine per event leads to data races when two events for the same channel overlap - for example, a PlaybackFinished arriving while a DTMF handler is still mutating the same channel state.

For production use, wrap the connection in a reconnect loop with exponential backoff so a temporary network interruption does not take down the application. On reconnect, discard any per-channel state from the previous connection - Asterisk has already torn down those channels.

The ARI REST calls your application makes throughout the call use HTTP Basic Auth against the same host:

OperationMethodEndpoint
Answer a channelPOST/channels/{id}/answer
Hang up a channelDELETE/channels/{id}
Play audioPOST/channels/{id}/play
Stop a playbackDELETE/playbacks/{id}
Create a bridgePOST/bridges
Destroy a bridgeDELETE/bridges/{id}
Add channel to bridgePOST/bridges/{id}/addChannel
Originate an outbound callPOST/channels

Step 2 - Identify the caller

The StasisStart event fires when an inbound call enters your Stasis application. Answer the channel immediately - Asterisk will time out an unanswered channel, and the caller hears silence until you answer - then use the caller ID to decide how to handle the call.

func (a *App) onStasisStart(event ARIEvent) {
channelID := event.Channel.ID
callerID := event.Channel.Caller.Number

s := a.newState(channelID, PhaseAnswering)
a.ari.Answer(channelID)

vip, _ := a.lookupVIP(callerID)
if vip != nil {
// VIP path: play a personalised greeting, then connect directly.
s.Phase = PhaseVIP
a.enqueue(channelID, s, []string{"sound:custom/vip-greeting"}, func() error {
return a.bridge(channelID, vip.AccountManagerExt)
})
} else {
// Regular path: collect caller input.
a.startIVR(channelID, s)
}
}

Caller ID is the only information available before the caller has interacted with the application, making it the natural place to apply pre-interaction routing logic. In addition to caller ID, StasisStart also carries the dialled number, channel variables set in the dialplan, and the initial channel state - all of which you can use to branch call handling before playing a single prompt. For example, you could route calls differently based on which number was dialled, or pass context from the dialplan into your application via channel variables.


Step 3 - Collect caller input

For regular callers, play a prompt and start accumulating DTMF digits. The application tracks a Phase and a digit buffer per channel, so concurrent calls never share state.

PhaseCollecting

digit received ──────────────── append to buffer
│ (reset inactivity timer; drop if > max_digits)
input timeout ──────────────── hang up

'#' received ────────────────── look up customer ID immediately

┌────────────┴────────────┐
found not found
│ │
play confirmation play "not recognised"
bridge to extension bridge to default destination

A per-channel state machine is the standard approach for call-flow logic in ARI. Because all calls share the same event handler, the phase tells the handler what a given DTMF digit means for that specific call - without it, state from one caller could be misread as belonging to another.

The ChannelDtmfReceived event fires once per key press. Append each digit to the buffer and reset the inactivity timer. When the caller presses #, perform the database lookup and route immediately without an extra confirmation step:

case PhaseCollecting:
if digit == "#" {
entered := s.Digits
s.Digits = ""
s.Phase = PhaseRouting
// Stop the inactivity timer - the caller has committed their input.
s.inputTimer.Stop()
s.mu.Unlock()

customer, _ := a.lookupCustomer(entered)
if customer != nil {
a.enqueue(channelID, s, []string{"sound:custom/id-recognized"}, func() error {
return a.bridge(channelID, customer.DestinationExt)
})
} else {
a.enqueue(channelID, s, []string{"sound:custom/id-not-recognized"}, func() error {
return a.bridge(channelID, a.cfg.DefaultDestination)
})
}
} else {
if len(s.Digits) >= a.cfg.MaxDigits {
s.mu.Unlock()
return // silently drop digits beyond the limit
}
s.Digits += digit
// Reset inactivity timer on every digit.
s.inputTimer.Reset(time.Duration(a.cfg.InputTimeout) * time.Second)
s.mu.Unlock()
}

Audio files are played sequentially using a media queue. The reason for the queue is that POST /channels/{id}/play is asynchronous - it returns immediately and fires a PlaybackFinished event when done. You cannot simply call it multiple times in a row; the second call would start before the first file finishes. The queue holds the list of files and starts the next one only after PlaybackFinished arrives for the current one. When the queue empties, the registered callback fires - here that triggers the bridge call.

Loading a new queue (enqueue) while audio is already playing calls DELETE /playbacks/{id} to stop the current file immediately, so the new sequence starts without waiting for the old one to finish. This matters if a DTMF digit arrives while a long prompt is still playing - the application can interrupt it and respond right away.

The media parameter in the play request accepts a URI scheme that controls what is played:

SchemeExampleWhat it plays
sound:sound:custom/welcomeA sound file from the sounds directory
digits:digits:1234Each digit spoken individually
number:number:42A number spoken as a word ("forty-two")
characters:characters:abcEach character spoken individually
tone:tone:busyA named tone

Step 4 - Route the call

The routing lookup and bridge setup happen immediately when the caller presses #. The database result determines which audio file plays and where the call goes; if no customer record exists for the entered ID, the call falls back to a default destination so it is never dropped.

Bridging is done by creating a mixing bridge, adding the inbound channel, and then originating an outbound call back into the same Stasis application. The bridge ID is passed as an argument to the origination so the outbound leg knows which bridge to join when its own StasisStart fires. A ring_timeout is passed as a query parameter so Asterisk automatically abandons the dial attempt if the destination does not answer within the configured limit.

func (a *App) bridge(channelID, endpoint string) error {
bridgeID, err := a.ari.CreateBridge()
if err != nil {
return fmt.Errorf("create bridge: %w", err)
}
if err := a.ari.AddChannelToBridge(bridgeID, channelID); err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("add to bridge: %w", err)
}
outboundID, err := a.ari.Originate(endpoint, a.cfg.ARI.App, "outbound,"+bridgeID, a.cfg.RingTimeout)
if err != nil {
_ = a.ari.DestroyBridge(bridgeID)
return fmt.Errorf("originate: %w", err)
}
// Store the bridge ID on the inbound leg's state so it can be destroyed on hangup.
if s := a.state(channelID); s != nil {
s.mu.Lock()
s.BridgeID = bridgeID
s.mu.Unlock()
}
// Track both directions so each side can hang up the other on disconnect.
a.outbound.Store(outboundID, channelID)
a.inbound.Store(channelID, outboundID)
return nil
}

// Handling the outbound leg in onStasisStart:
if len(event.Args) > 1 && event.Args[0] == "outbound" {
bridgeID := event.Args[1]
a.ari.AddChannelToBridge(bridgeID, channelID)
return
}

If AddChannelToBridge or Originate fails after the bridge has been created, the bridge is destroyed immediately to avoid leaking an empty bridge object in Asterisk.

The App keeps two maps - inbound (inbound ID → outbound ID) and outbound (outbound ID → inbound ID). When StasisEnd fires for either leg, the application looks up the partner channel, hangs it up, and then destroys the bridge. ARI does not auto-destroy a mixing bridge when it empties, so destroyBridge must be called explicitly once both legs are gone. Without this cleanup, a caller who hangs up before the agent answers would leave the outbound leg ringing indefinitely, and an empty bridge object would persist in Asterisk.

func (a *App) onStasisEnd(event ARIEvent) {
channelID := event.Channel.ID

// Inbound caller hung up - clean up the outbound leg and the bridge.
if v, ok := a.inbound.LoadAndDelete(channelID); ok {
outboundID := v.(string)
a.outbound.Delete(outboundID)
a.ari.Hangup(outboundID)
a.destroyBridge(channelID)
}

// Outbound leg ended - hang up the caller and destroy the bridge.
if v, ok := a.outbound.LoadAndDelete(channelID); ok {
inboundID := v.(string)
a.inbound.Delete(inboundID)
a.ari.Hangup(inboundID)
a.destroyBridge(inboundID)
a.channels.Delete(inboundID)
}

a.channels.Delete(channelID)
}

Using a bridge rather than a simple transfer keeps both call legs under your application's control for the lifetime of the call. This means you can add further behaviour on top - playing hold music while the agent is being connected, recording the conversation via POST /bridges/{id}/record, or allowing a supervisor to barge in by adding a third channel to the same bridge.


Complete example

The following tabs contain the full source for the sample application described in this guide.

Install: npm install ws mysql2

app.mjs

import http from 'node:http';
import { createConnection } from 'mysql2/promise';
import WebSocket from 'ws';

// ---------- Config ----------

const cfg = {
ari: {
host: 'your-pbxware-host',
username: 'your-ari-username',
password: 'your-ari-password',
app: 'your-app-name',
},
db: { host: 'your-db-host', port: 3306, user: 'your-db-user', password: 'your-db-password', database: 'your-db-name' },
defaultDestination: 'PJSIP/1000',
ringTimeout: 30,
inputTimeout: 30,
maxDigits: 20,
};

// ---------- ARI REST ----------

function ariCall(method, path, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const options = {
hostname: cfg.ari.host,
path: '/ari' + path,
method,
auth: `${cfg.ari.username}:${cfg.ari.password}`,
headers: data
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) }
: {},
};
const req = http.request(options, (res) => {
let buf = '';
res.on('data', (chunk) => (buf += chunk));
res.on('end', () => {
if (res.statusCode >= 400)
return reject(new Error(`ARI ${method} ${path}${res.statusCode}: ${buf}`));
try { resolve(JSON.parse(buf)); } catch { resolve({}); }
});
});
req.on('error', reject);
if (data) req.write(data);
req.end();
});
}

const ari = {
answer: (id) => ariCall('POST', `/channels/${id}/answer`),
hangup: (id) => ariCall('DELETE', `/channels/${id}`),
play: (id, media) => ariCall('POST', `/channels/${id}/play`, { media }).then((r) => r.id ?? ''),
stopPlay: (pbId) => ariCall('DELETE', `/playbacks/${pbId}`),
createBridge: () => ariCall('POST', '/bridges', { type: 'mixing' }).then((r) => r.id ?? ''),
destroyBridge:(bId) => ariCall('DELETE', `/bridges/${bId}`),
addToBridge: (bId, chId) => ariCall('POST', `/bridges/${bId}/addChannel`, { channel: chId }),
originate: (ep, appName, args, tOut) => ariCall('POST', `/channels?timeout=${tOut}`, {
endpoint: ep, app: appName, appArgs: args, callerId: 'ARI App',
}).then((r) => r.id ?? ''),
};

// ---------- State machine ----------

const Phase = Object.freeze({
Answering: 'answering',
VIP: 'vip_greeting',
Collecting: 'collecting_id',
Routing: 'routing',
});

const channels = new Map(); // channelID → state
const outbound = new Map(); // outboundID → inboundID
const inbound = new Map(); // inboundID → outboundID

// ---------- Media queue ----------

async function enqueue(id, s, media, onDone) {
if (s.pbId) {
try { await ari.stopPlay(s.pbId); } catch { /* already gone */ }
s.pbId = '';
}
s.queue = [...media];
s.onDone = onDone ?? null;
await playNext(id, s);
}

async function playNext(id, s) {
if (s.queue.length === 0) {
const cb = s.onDone;
s.onDone = null;
if (cb) { try { await cb(); } catch (e) { console.error(`[${id}] queue callback:`, e); } }
return;
}
const media = s.queue.shift();
try {
s.pbId = await ari.play(id, media);
} catch (e) {
console.error(`[${id}] play ${media} failed - skipping:`, e);
await playNext(id, s);
}
}

// ---------- Input timer ----------

function resetInputTimer(id, s) {
if (s.inputTimer) clearTimeout(s.inputTimer);
s.inputTimer = setTimeout(() => {
if (s.phase === Phase.Collecting) {
console.log(`[${id}] input timeout - hanging up`);
ari.hangup(id).catch(() => {});
}
}, cfg.inputTimeout * 1000);
}

function clearInputTimer(s) {
if (s.inputTimer) { clearTimeout(s.inputTimer); s.inputTimer = null; }
}

// ---------- Bridging ----------

async function bridgeCall(id, endpoint) {
const bridgeId = await ari.createBridge();
try { await ari.addToBridge(bridgeId, id); }
catch (e) { await ari.destroyBridge(bridgeId).catch(() => {}); throw e; }

let outboundId;
try { outboundId = await ari.originate(endpoint, cfg.ari.app, `outbound,${bridgeId}`, cfg.ringTimeout); }
catch (e) { await ari.destroyBridge(bridgeId).catch(() => {}); throw e; }

const s = channels.get(id);
if (s) s.bridgeId = bridgeId;
outbound.set(outboundId, id);
inbound.set(id, outboundId);
console.log(`[${id}] bridging to ${endpoint} via ${bridgeId} (outbound: ${outboundId})`);
}

async function destroyBridgeFor(channelId) {
const s = channels.get(channelId);
if (!s || !s.bridgeId) return;
const bId = s.bridgeId;
s.bridgeId = '';
await ari.destroyBridge(bId).catch((e) => console.error(`[${channelId}] destroy bridge:`, e));
}

// ---------- DB ----------

let db;

async function lookupVIP(callerId) {
const [rows] = await db.execute(
'SELECT name, account_manager_ext FROM vip_callers WHERE caller_id = ?', [callerId]);
return rows[0] ?? null;
}

async function lookupCustomer(customerId) {
const [rows] = await db.execute(
'SELECT name, destination_ext FROM customers WHERE customer_id = ?', [customerId]);
return rows[0] ?? null;
}

// ---------- Event handlers ----------

async function onStasisStart(event) {
const id = event.channel?.id;
if (!id) return;

if (event.args?.[0] === 'outbound') {
const bridgeId = event.args[1];
console.log(`[${id}] outbound leg → bridge ${bridgeId}`);
await ari.addToBridge(bridgeId, id).catch((e) => console.error(`[${id}] add outbound to bridge:`, e));
return;
}

const callerId = event.channel.caller?.number ?? '';
console.log(`[${id}] incoming call from "${callerId}"`);

const s = { phase: Phase.Answering, digits: '', queue: [], onDone: null, pbId: '', bridgeId: '', inputTimer: null };
channels.set(id, s);

try { await ari.answer(id); } catch (e) { console.error(`[${id}] answer:`, e); return; }

let vip = null;
try { vip = await lookupVIP(callerId); } catch (e) { console.error(`[${id}] VIP lookup:`, e); }

if (vip) {
console.log(`[${id}] VIP: ${vip.name}${vip.account_manager_ext}`);
s.phase = Phase.VIP;
await enqueue(id, s, ['sound:custom/vip-greeting'], () => bridgeCall(id, vip.account_manager_ext));
} else {
await startIVR(id, s);
}
}

async function startIVR(id, s) {
s.phase = Phase.Collecting;
s.digits = '';
resetInputTimer(id, s);
await enqueue(id, s, ['sound:custom/welcome'], null);
}

async function onPlaybackFinished(event) {
const targetURI = event.playback?.target_uri ?? '';
const id = targetURI.replace(/^channel:/, '');
const s = channels.get(id);
if (!s) return;
if (s.pbId === event.playback?.id) await playNext(id, s);
}

async function onDTMF(event) {
const id = event.channel?.id;
if (!id) return;
const digit = event.digit ?? '';
const s = channels.get(id);
if (!s || s.phase !== Phase.Collecting) return;

if (digit === '#') {
const entered = s.digits;
s.digits = '';
s.phase = Phase.Routing;
clearInputTimer(s);

if (!entered) { await startIVR(id, s); return; }
console.log(`[${id}] customer ID entered: "${entered}"`);

let customer = null;
try { customer = await lookupCustomer(entered); }
catch (e) { console.error(`[${id}] customer lookup:`, e); await startIVR(id, s); return; }

if (customer) {
console.log(`[${id}] customer found: ${customer.name}${customer.destination_ext}`);
await enqueue(id, s, ['sound:custom/id-recognized'], () => bridgeCall(id, customer.destination_ext));
} else {
console.log(`[${id}] customer not found → ${cfg.defaultDestination}`);
await enqueue(id, s, ['sound:custom/id-not-recognized'], () => bridgeCall(id, cfg.defaultDestination));
}
} else {
if (s.digits.length >= cfg.maxDigits) {
console.log(`[${id}] DTMF '${digit}' ignored - digit limit reached`);
return;
}
s.digits += digit;
console.log(`[${id}] DTMF '${digit}' collected (so far: "${s.digits}")`);
resetInputTimer(id, s);
}
}

async function onStasisEnd(event) {
const id = event.channel?.id;
if (!id) return;
console.log(`[${id}] channel ended`);

const s = channels.get(id);
if (s) clearInputTimer(s);

if (inbound.has(id)) {
const outId = inbound.get(id); inbound.delete(id); outbound.delete(outId);
await ari.hangup(outId).catch((e) => console.error(`[${id}] hangup outbound:`, e));
await destroyBridgeFor(id);
}

if (outbound.has(id)) {
const inId = outbound.get(id); outbound.delete(id); inbound.delete(inId);
console.log(`[${id}] outbound ended, hanging up caller ${inId}`);
await ari.hangup(inId).catch((e) => console.error(`[${id}] hangup caller:`, e));
await destroyBridgeFor(inId);
channels.delete(inId);
}

channels.delete(id);
}

async function dispatch(event) {
switch (event.type) {
case 'StasisStart': await onStasisStart(event); break;
case 'PlaybackFinished': await onPlaybackFinished(event); break;
case 'ChannelDtmfReceived': await onDTMF(event); break;
case 'StasisEnd': await onStasisEnd(event); break;
}
}

// ---------- Main ----------

async function main() {
db = await createConnection(cfg.db);
console.log('DB connected');

const wsURL = `ws://${cfg.ari.host}/ari/events?${new URLSearchParams({ app: cfg.ari.app })}`;
const authHeader = 'Basic ' + Buffer.from(`${cfg.ari.username}:${cfg.ari.password}`).toString('base64');
const ws = new WebSocket(wsURL, { headers: { Authorization: authHeader } });

// Chain each event onto the previous one so they are handled strictly in
// arrival order - ws emits 'message' as soon as a frame arrives, so without
// this a slow async handler for one event could still be running when the
// next event for the same channel comes in.
let dispatchQueue = Promise.resolve();

ws.on('open', () => console.log('Connected to Asterisk ARI'));
ws.on('message', (data) => {
let event;
try { event = JSON.parse(data.toString()); } catch { return; }
console.log('←', event.type);
dispatchQueue = dispatchQueue.then(() => dispatch(event)).catch((e) => console.error('dispatch error:', e));
});
ws.on('close', () => { console.log('Connection closed'); process.exit(0); });
ws.on('error', (e) => { console.error('WebSocket error:', e); process.exit(1); });

process.on('SIGINT', () => { console.log('Shutting down'); process.exit(0); });
}

main().catch((e) => { console.error(e); process.exit(1); });

Run the application

node app.mjs

The application connects to Asterisk, logs Connected to Asterisk ARI, and begins processing calls. Any inbound call routed to the configured Stasis application will be handled by the application.