Production-ready FreeSWITCH module that transforms your PBX into a cloud-native microservice with real-time event streaming and remote command execution via NATS message broker.
mod_event_agent is a high-performance FreeSWITCH module that enables:
- π― Remote API Control: Execute any FreeSWITCH command from external services
- π‘ Real-Time Event Streaming: Publish FreeSWITCH events to message brokers
- ποΈ Dynamic Dialplan Control: Park/unpark calls with audio modes (silence, ringback, music)
- π Bidirectional Communication: Request-reply and pub/sub patterns
- π Multi-Node Support: Cluster-aware with node identification
- π Production Performance: 10k+ commands/sec, <1ms latency
- Call Center Integration: Control FreeSWITCH from CRM/ERP systems
- Smart IVR: Dynamic dialplan management from external business logic
- Real-Time Analytics: Stream call events to data pipelines
- Multi-Tenant Systems: Isolated control per tenant with node routing
- WebRTC Gateways: Bridge SIP/WebRTC with external signaling
Every synchronous command reply comes in the same JSON envelope so client code can be minimal and safe:
{
"success": true,
"status": "success",
"message": "API command executed",
"timestamp": 1736123456789012,
"node_id": "fs-node-01",
"data": "optional payload"
}timestampis expressed in microseconds since epoch for maximum resolution.node_idis always present (or "unknown" if the node was not configured).- Handlers can extend the payload with extra keys like
mode,enabled, orinfo, but the envelope is guaranteed.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NATS MESSAGE BROKER β
β (Pub/Sub + Request/Reply) β
β β
β Subjects: β
β β’ freeswitch.api β Broadcast command lane β
β β’ freeswitch.node.{node_id} β Direct node lane β
β β’ freeswitch.events.* β Events (pub/sub) β
ββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββ¬βββββββββββββββ
β β
ββββββΌββββββββββ ββββββββββΌβββββββββββ
β Clients β β Event Consumers β
β β β β
β β’ Python β β β’ Analytics β
β β’ Node.js β β β’ CDR Processing β
β β’ Go/Java β β β’ Monitoring β
β β’ Any Lang β β β’ ML Pipelines β
ββββββββββββββββ βββββββββββββββββββββ
β² β²
β β
ββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββ
β mod_event_agent β
β ββββββββββββββββ βββββββββββββββββ ββββββββββββββββββββββββ β
β β Commands β β Events β β Dialplan β β
β β Handler β β Adapter β β Manager β β
β β β β β β β β
β β β’ API calls β β β’ Streaming β β β’ Park mode β β
β β β’ Originate β β β’ Filtering β β β’ Audio control β β
β β β’ Bridge β β β’ JSON format β β β’ Dynamic XML β β
β ββββββββ¬ββββββββ βββββββββ¬ββββββββ βββββββββββ¬βββββββββββββ β
β β β β β
β ββββββββββββββββββββ΄ββββββββββββββββββββββ β
β β β
β ββββββββββΌβββββββββ β
β β NATS Driver β β
β β β’ Pub/Sub β β
β β β’ Req/Reply β β
β β β’ Auto-reconnectβ β
β βββββββββββββββββββ β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββΌββββββββββββ
β FreeSWITCH Core β
β β’ Event System β
β β’ API Engine β
β β’ XML Dialplan β
β β’ Call Processing β
ββββββββββββββββββββββββ
- Publish to
freeswitch.apifor broadcast commands. Optionally add"node_id":"fs-node-01"in the payload to have a single node pick it up. - Publish to
freeswitch.node.{node_id}when you want to address a specific FreeSWITCH node directly (nonode_idfield required). - Every payload must include a
commandstring. Built-in handlers coveroriginate,hangup,dialplan.enable,dialplan.disable,dialplan.audio,dialplan.autoanswer,dialplan.status, andagent.status. Any other value falls back to native FreeSWITCHapiexecution, so{"command":"show","args":"channels"}still works. - Add
"async": trueto make any command fire-and-forget. The request will be executed but no reply will be published; errors are still logged server-side for observability.
This registry-driven approach keeps clients simple (only two subjects to remember) while letting the server retain full validation, RBAC, and telemetry per command name.
Execute any FreeSWITCH API command remotely:
nats req freeswitch.api '{"command":"status"}'
nats req freeswitch.api '{"command":"show","args":"channels"}'
nats req freeswitch.api '{"command":"reloadxml"}'Create outbound calls with full control:
{
"command": "originate",
"endpoint": "user/1000",
"destination": "&park",
"caller_id_name": "Bot",
"caller_id_number": "5551234",
"variables": {"custom_var": "value"}
}Connect two legs dynamically using native FreeSWITCH commands:
{
"command": "uuid_bridge",
"args": "abc-123-uuid sofia/gateway/provider/5551234"
}Invoke any FreeSWITCH dialplan APP (e.g. play_and_get_digits, bridge,
ivr, record, playback, set, transfer) on a live channel β without
the dialplan XML. APPs are registered via SWITCH_ADD_APP (distinct from
API verbs); the generic api handler cannot reach them because
switch_api_execute only resolves API verbs. execute_app fills that gap
by calling switch_core_session_execute_application_async directly β the
same C entry point dialplan XML uses internally.
{
"command": "execute_app",
"uuid": "<channel-uuid>",
"app": "play_and_get_digits",
"args": "1 4 3 5000 # tone_stream://%(300,0,440) /invalid.wav my_digits \\d+ 2000 -"
}Required: uuid, app. Optional: args (passed verbatim as a single
string β spaces in args are preserved exactly, no internal tokenization).
Dispatch is always async. The reply confirms the app was queued on the channel's runtime thread:
{ "success": true, "message": "execute_app dispatched" }The app's actual outcome (digits captured, file played, regex match, etc.)
flows back via the event bus as CHANNEL_EXECUTE_COMPLETE. Subscribe
to freeswitch.events.channel.execute_complete filtered by Unique-ID
and Application to observe completion. Why async: a sync wrapper would
block the module's single dispatch thread for the entire duration of the
app (potentially many seconds for play_and_get_digits), serializing all
other concurrent NATS commands behind it. Async returns in Β΅s and keeps
the bus hot.
Failure modes:
- Pre-dispatch (missing uuid, missing app, channel not found, queue
rejection) β reply
success:falsewith a descriptive message. - Post-dispatch (app rejected its args, regex didn't match, channel
hung up mid-execution, internal app timeout) β reply was already
success:true. Failure surfaces in theCHANNEL_EXECUTE_COMPLETEevent headers (Application-Response,variable_read_result, etc.).
Real-time module metrics:
{
"command": "agent.status",
"version": "2.0.0",
"uptime": 3600,
"events_published": 12345,
"commands_received": 5432,
"driver": "nats",
"connected": true
}Every command accepts an optional forward_to field. When present, the
same response envelope that goes to the NATS request/reply inbox is
ALSO published to the supplied subject. Independent of async: a
fire-and-forget caller can still direct the result to an observer
(metrics, audit, side-branch workflows) without holding a request/reply
socket open.
{
"command": "execute_app",
"uuid": "abc-123",
"app": "play_and_get_digits",
"args": "1 4 3 5000 # ...",
"forward_to": "metrics.voip.pagd.results"
}Semantics:
- Best-effort: publish failures to
forward_toare logged but never bubble up to the caller's reply. Caller's primary contract is unchanged. - Identical payload: the bytes published to
forward_toare byte-for- byte the same as the NATS inbox reply β predictable for observers. - Independent of
async: works when caller usednc.Request()(gets reply + forward) AND when caller usednc.Publish()withasync:true(no reply, but forward still fires). - No correlation framing: if the consumer needs to tie back to a
specific request, include a
correlation_idin the original payload and the handler will echo it in the response.
Common patterns:
- Metrics fan-out:
forward_to: "metrics.cmd.results"β separate consumer collects latencies + outcomes without touching the caller path. - Audit trail:
forward_to: "audit.fs.commands"β durable log of every dispatched command across the cluster. - Workflow callback:
forward_to: "workflow.callbacks.exec-42.node-3"β engine resumes the suspended step when the response arrives.
Every built-in command now uses the lightweight validators under src/validation/. They provide
type-safe binding and descriptive errors without relying on giant schema files or runtime
allocations. The helpers follow the v_<type>() pattern and automatically write the sanitized
value into your payload struct:
typedef struct {
char endpoint[256];
char extension[256];
} call_originate_payload_t;
call_originate_payload_t payload = {0};
const char *err = NULL;
if ((err = v_string(request->payload, &payload, endpoint,
v_len(1, 255),
"endpoint must be between 1 and 255 characters"))) {
return command_result_error(err);
}
if ((err = v_enum(request->payload, &payload, mode,
"mode must be silence, ringback, or music",
"silence", "ringback", "music"))) {
return command_result_error(err);
}Available helpers:
v_string/v_string_optwithv_len,v_len_min,v_len_maxv_number/v_number_optwithv_rangerulesv_bool/v_bool_optv_enum/v_enum_opt
They short-circuit on the first failure so command handlers stay tiny while clients receive human readable messages.
Control call flow without reloading dialplan:
# Enable park with ringback tone
nats req freeswitch.api '{"command":"dialplan.enable"}'
nats req freeswitch.api '{"command":"dialplan.audio","mode":"ringback"}'
# Music on hold
nats req freeswitch.api '{"command":"dialplan.audio","mode":"music","music_class":"moh"}'
# Silent park
nats req freeswitch.api '{"command":"dialplan.audio","mode":"silence"}'
# Disable park (return to normal dialplan)
nats req freeswitch.api '{"command":"dialplan.disable"}'{
"command": "dialplan.autoanswer",
"enabled": true // Auto-answer parked calls
}Use Cases:
- Queue management (park until agent available)
- Call recording preparation
- IVR delays with custom audio
- Emergency broadcast mode
Stream FreeSWITCH events in real-time:
Configurable Filtering:
<param name="include-events" value="CHANNEL_CREATE,CHANNEL_DESTROY,CHANNEL_ANSWER"/>
<param name="exclude-events" value="HEARTBEAT,PRESENCE_IN"/>Event Format (JSON):
{
"event_name": "CHANNEL_ANSWER",
"timestamp": 1733433600000000,
"node_id": "fs_node_01",
"uuid": "abc-123-uuid",
"headers": {
"Caller-Destination-Number": "5551234",
"Channel-State": "CS_EXECUTE"
}
}Published to: freeswitch.events.channel.answer, freeswitch.events.channel.create, etc.
The subject builder lowercases the FS event name AND converts every
_ character to .. Subscribers MUST mirror that grammar or they
will silently miss events. Examples:
| FreeSWITCH event name (C constant) | NATS subject published |
|---|---|
CHANNEL_CREATE |
<prefix>.events.channel.create |
CHANNEL_ANSWER |
<prefix>.events.channel.answer |
CHANNEL_EXECUTE |
<prefix>.events.channel.execute |
CHANNEL_EXECUTE_COMPLETE |
<prefix>.events.channel.execute.complete |
CHANNEL_HANGUP |
<prefix>.events.channel.hangup |
CHANNEL_HANGUP_COMPLETE |
<prefix>.events.channel.hangup.complete |
DETECTED_SPEECH |
<prefix>.events.detected.speech |
RECORD_START / RECORD_STOP |
<prefix>.events.record.start / .stop |
RECV_INFO |
<prefix>.events.recv.info |
Common pitfall: subscribers writing channel.execute_complete (with
the underscore preserved) get NOTHING because the publisher path
emitted channel.execute.complete instead. Use NATS wildcards to
cover both intents when you don't want to track every dot manually:
# Everything channel-scoped (recommended for app-completion observers)
nats sub "freeswitch.events.channel.>"
# Only execute lifecycle (start + complete)
nats sub "freeswitch.events.channel.execute.>"The conversion is implemented in src/events/adapter.c::build_subject:
*p = tolower(*p);
if (*p == '_') {
*p = '.';
}This is intentional β dotted subjects play better with NATS subject hierarchies and ACLs than underscores do β but it is NOT obvious from the FS event name alone, hence this section.
Route commands to specific nodes:
Broadcast (all nodes, filtered):
{"command": "status", "node_id": "fs_node_01"}Direct (specific node):
Subject: freeswitch.api.fs_node_01
Payload: {"command": "status"}| Metric | Value |
|---|---|
| Command Throughput | 10,000+ req/sec |
| Latency (local) | <1ms p99 |
| Event Overhead | <0.1% CPU |
| Memory | ~5MB baseline |
| Network | <100 KB/s idle |
The module is designed for an internal, trusted network where the NATS broker is the perimeter. Hardening lives at three layers, each adding defence in depth:
Every credential is sourced from the environment, never from the XML config (so a checked-in repo never leaks secrets):
| Env var | Purpose |
|---|---|
MOD_EVENT_AGENT_TOKEN |
NATS auth token (recommended) |
MOD_EVENT_AGENT_NKEY_SEED |
NATS NKey seed (alternative to token) |
MOD_EVENT_AGENT_URL |
Broker URL (e.g. nats://nats:4222) |
MOD_EVENT_AGENT_NODE_ID |
Per-node identifier |
Env values override anything declared in the XML. The token / nkey
fields in event_agent.conf.xml are deprecated and emit a warning
when populated.
| Guard | What it does |
|---|---|
| Payload size cap | Rejects any inbound JSON over 64 KB before parsing β backstop against an OOM-by-publish attack. |
| Empty payload guard | A NULL or zero-byte body is rejected with a distinct message instead of being mis-classified as bad JSON. |
| Optional API denylist | The generic-API fallback supports an opt-in denylist (MOD_EVENT_AGENT_API_DENYLIST="cmd1,cmd2,..." or the matching XML <param name="api_denylist">). Default is empty β the module forwards every API verb to FreeSWITCH, behaving as a transparent ESL-over-NATS bridge. The list is a defense-in-depth knob for sandbox / compliance deployments; primary authorization belongs at the NATS layer (token, NKey, subject ACL, JWT claims), not here. |
| Subject-prefix sanitisation | The configurable subject_prefix is capped at 64 chars and any character outside [a-zA-Z0-9._-] is replaced with _ β eliminates a CR/LF wire-protocol injection surface from a hostile config. |
| Subscription cap | The driver refuses to register more than 32 simultaneous subscriptions (the module legitimately needs β€ 2). |
Every command attempt emits a single INFO line of the shape
[mod_event_agent] audit=command cmd=<name> subject=<subj> result=ok|err
async=yes|no payload_bytes=<size>
Payload contents are deliberately not logged β event_context
may carry PII. Operators grep audit=command for the trail.
See INSTALL.md for the operator runbook including how to verify
each guard with the bundled tests.
The fastest path to a working module in a dev stack:
# 1. Build the FS+module image (the Dockerfile bundles libnats + the .so)
docker build -t freeswitch-events-agent:latest -f Dockerfile.freeswitch .
# 2. Run it pointing at any NATS broker on your network
docker run -d --name fs \
-e MOD_EVENT_AGENT_URL=nats://nats:4222 \
-e MOD_EVENT_AGENT_TOKEN=$NATS_TOKEN \
-e MOD_EVENT_AGENT_NODE_ID=fs-prod-01 \
freeswitch-events-agent:latest
# 3. Smoke-check from any host that can reach the same broker
nats --server nats://nats:4222 \
req freeswitch.api '{"command":"agent.status"}' --timeout 3sFor the canonical FreeSWITCH-native install (drop-in inside
freeswitch/src/mod/applications/, autotools build), or for the
standalone Makefile path used during development, see INSTALL.md.
mod_event_agent writes straight through switch_log_printf, so
verbosity follows the regular FreeSWITCH logging knobs:
fs_cli -x "log debug"
mod_events_agent/
βββ src/
β βββ mod_event_agent.c # Module entry point
β βββ mod_event_agent.h # Main header
β βββ core/
β β βββ config.c # XML + env config parser
β βββ events/
β β βββ adapter.c # FS event subscription & publishing
β β βββ serializer.c # JSON serialization
β βββ dialplan/
β β βββ manager.c # XML binding & park mode
β β βββ commands.c # Dialplan-control commands
β βββ commands/
β β βββ handler.c # Command dispatcher (size cap, audit log)
β β βββ core.c # Request envelope helpers
β β βββ api.c # Generic API execution + optional denylist
β β βββ call.c # Originate / hangup
β β βββ status.c # agent.status
β βββ validation/
β β βββ validation.c # Typed payload validators
β β βββ validation.h # v_len / v_range macros
β βββ drivers/
β βββ interface.h # Driver interface
β βββ nats.c # NATS driver (subscription cap, leak fixes)
β
βββ tests/
β βββ Makefile # Builds the four test binaries
β βββ run.sh # Orchestrator (build + smoke + run)
β βββ src/
β βββ show_modules_test.c # Legacy quick-check
β βββ test_size_limit.c # Verifies the 64KB cap
β βββ test_denylist.c # Opt-in denylist: SKIP unless env set, else asserts denial
β βββ test_validation.c # 11 invalid-payload cases
β
βββ docs/
β βββ API.md # Complete API reference
β βββ DIALPLAN_CONTROL.md # Dialplan control guide
β βββ ROADMAP.md # Driver development roadmap
β
βββ m4/
β βββ ax_lib_nats.m4 # Autoconf detection for libnats
βββ autoload_configs/
β βββ mod_event_agent.conf.xml # Config template (env vars preferred)
β
βββ INSTALL.md # Operator install guide (3 paths)
βββ Makefile # Standalone build (custom)
βββ Makefile.am # In-tree FreeSWITCH build (autotools)
βββ configure.ac.snippet # FS configure.ac integration recipe
βββ Dockerfile # Module-only image (build artifacts)
βββ Dockerfile.freeswitch # FS + module pre-baked image
βββ docker-compose.dev.yaml # Local dev stack (FS + NATS)
βββ install.sh # In-container helper installer
| Command | Description | Reply |
|---|---|---|
originate |
Create outbound call with endpoint/extension/context fields | β Yes |
hangup |
Terminate a UUID with optional cause |
β Yes |
agent.status |
Module stats (version + metrics) | β Yes |
dialplan.enable |
Enable park mode | β Yes |
dialplan.disable |
Disable park mode | β Yes |
dialplan.audio |
Configure park audio (mode, optional music_class) |
β Yes |
dialplan.autoanswer |
Toggle auto-answer for parked calls | β Yes |
dialplan.status |
Snapshot of park manager state | β Yes |
Any other command value is passed directly to the native FreeSWITCH API, so "command":"status", "command":"show", "command":"uuid_bridge", etc., keep working without extra configuration.
βΉοΈ Bridge, transfer, and media manipulation go through the native API fallback with commands such as
uuid_bridge,uuid_transfer,uuid_broadcast, etc.
Add "async": true to any payload when you do not need a reply. The server still executes the handler, updates metrics, and logs errors, but the request immediately returns on the client side.
| Subject Pattern | Description |
|---|---|
freeswitch.events.channel.* |
Channel lifecycle events |
freeswitch.events.call.* |
Call-related events |
freeswitch.events.custom.* |
Custom events |
Full API documentation: docs/API.md
<param name="driver" value="nats"/> <!-- Driver: nats (others in roadmap) -->
<param name="url" value="nats://host:4222"/> <!-- Broker connection URL -->
<param name="subject_prefix" value="freeswitch"/> <!-- Subject prefix (freeswitch.api, freeswitch.node.*) -->
<param name="node-id" value="fs-node-01"/> <!-- Unique node identifier -->Three install paths are documented in INSTALL.md. Quick summary:
| Path | When |
|---|---|
| A. In-tree FreeSWITCH build | You compile FS from source. Module sits at freeswitch/src/mod/applications/mod_event_agent/ and is built by FS's autotools β same pattern as mod_skel, mod_amqp, etc. Requires Makefile.am (in this repo) plus the three configure.ac edits documented in configure.ac.snippet. |
| B. Standalone build | You have FS pre-installed with headers available. make && sudo make install against the included Makefile. Useful for local iteration. |
| C. Docker image | Use Dockerfile.freeswitch to bake the module into a complete FS image. Used in docker-compose.dev.yaml for the dev stack. |
- FreeSWITCH 1.10+ (headers available, either via FS source tree or
installed at
/usr/local/freeswitch/include/freeswitch) - Linux/Unix
- gcc, make, pkg-config
libcjson-devβ₯ 1.7libssl-devlibnatsheaders + lib (system install OR the bundledlib/nats/libnats_static.afor the standalone path)
The module registers a single XML binding (the dynamic dialplan) at
load. Register it in modules.conf.xml after the standard
endpoint/codec modules so any park-mode interception only takes
effect once the rest of the dialplan is wired:
<load module="mod_dialplan_xml"/>
...
<load module="mod_event_agent"/>Two layers, in precedence order:
export MOD_EVENT_AGENT_URL=nats://nats:4222
export MOD_EVENT_AGENT_TOKEN=<auth-token> # OR β¦
export MOD_EVENT_AGENT_NKEY_SEED=<nkey-seed>
export MOD_EVENT_AGENT_NODE_ID=fs-prod-01Env wins over XML and is the only path that should carry secrets.
Used for non-secret fields (event filters, subject prefix) and as a
fallback for the URL/node id when env is absent. The reference file
shipped at autoload_configs/mod_event_agent.conf.xml documents
every supported parameter inline.
<configuration name="event_agent.conf">
<settings>
<param name="driver" value="nats"/>
<param name="url" value="nats://$${nats_host}:$${nats_port}"/>
<!-- Leave token / nkey_seed BLANK in production. The module
picks them up from env vars; a non-empty value here logs a
DEPRECATED warning at module load. -->
<param name="token" value=""/>
<param name="nkey_seed" value=""/>
<!-- Event filtering -->
<param name="publish_all_events" value="true"/>
<param name="exclude" value="DTMF,HEARTBEAT"/>
<param name="subject_prefix" value="freeswitch"/>
<param name="node_id" value="$${agent_node_id}"/>
</settings>
</configuration>Reconnect / timeout knobs are baked into the driver (
MaxReconnect=60,ReconnectWait=1s,ReconnectBufSize=8MB) and are not currently configurable. Open an issue if you need them exposed.
For FreeSWITCH clusters, assign a unique node-id to each node:
<!-- Node 1 -->
<param name="node-id" value="fs-node-01"/>
<!-- Node 2 -->
<param name="node-id" value="fs-node-02"/>Clients can filter responses by node_id in the JSON response.
# Docker (~10MB image)
docker run -d --name nats -p 4222:4222 nats:latest
# Or direct binary (https://nats.io/download/)
wget https://github.com/nats-io/nats-server/releases/download/v2.10.7/nats-server-v2.10.7-linux-amd64.tar.gz
tar xzf nats-server-*.tar.gz
./nats-serverBuild the helper binaries under tests/ with the provided Makefile, then run:
cd tests
# service_a client: Sends commands and receives responses
LD_LIBRARY_PATH=../lib/nats ./bin/service_a_nats '{"command":"status"}'
# service_b client: Processes commands (simulation)
LD_LIBRARY_PATH=../lib/nats ./bin/service_b_nats
# simple client: Multi-mode (pub/req/server)
LD_LIBRARY_PATH=../lib/nats ./bin/simple_test req freeswitch.api '{"command":"version"}'# System status
./bin/show_modules_test
# β {"success":true,"message":"API command executed","data":"(list modules)","timestamp":...,"node_id":"fs-node-01"}
See docs/API.md for complete command documentation.
| Aspect | mod_event_agent + NATS | ESL (Event Socket Library) |
|---|---|---|
| Protocol | NATS (text, open standard) | Proprietary binary |
| Dependencies | None (static lib) | libesl + ~7MB deps |
| Debugging | telnet, nats CLI, any tool |
Specific ESL client |
| Languages | Any with NATS client | Specific bindings (Node, Python, etc.) |
| Latency | 0.5-1ms (local) | 2-5ms |
| Throughput | ~10,000 req/s | ~1,000 req/s |
| Scalability | Native (NATS clustering) | Requires proxy/balancer |
| Event Streaming | Native Pub/Sub | Socket connection 1:1 |
| Multi-Node | Yes (node filtering) | Multiple connections |
- β 100,000 requests: 100% success rate
- β 50 concurrent clients: No packet loss
- β Production: 1,055 requests, 99.7% success
- β Latency: <100ms (average <1ms local)
βββββββββββββββ βββββββββββββββ βββββββββββββββ
β Service A ββββββΆβ NATS βββββββ Service B β
β (Node.js) β β Broker β β (Python) β
βββββββββββββββ ββββββββ¬βββββββ βββββββββββββββ
β
ββββββββΌβββββββ
β FreeSWITCH β
β mod_event β
β _agent β
βββββββββββββββ
- Multiple services control FreeSWITCH without direct dependencies
- Horizontal broker scalability
- Heterogeneous languages (Node, Python, Go, Java, etc.)
FreeSWITCH Events β NATS β [
β’ Analytics Service (Python)
β’ Billing Service (Go)
β’ Notification Service (Node.js)
β’ CDR Storage (Java)
]
- Real-time event streaming
- Parallel event processing
- Total decoupling between producers and consumers
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β FreeSWITCH 1 β β FreeSWITCH 2 β β FreeSWITCH 3 β
β (New York) β β (London) β β (Tokyo) β
βββββββββ¬βββββββ βββββββββ¬βββββββ βββββββββ¬βββββββ
β β β
βββββββββββββββββββ΄βββββββββββββββββββ
β
βββββββΌββββββ
β NATS β
β Cluster β
βββββββ¬ββββββ
β
βββββββββββ΄ββββββββββ
βββββββββΌβββββββββ ββββββββΌββββββββ
β Control Panel β β Monitor β
β (Web UI) β β Dashboard β
ββββββββββββββββββ ββββββββββββββββ
- Centralized control of multiple FreeSWITCH nodes
- Geographic load balancing
- Real-time global monitoring
The repo ships an integration test suite under tests/ that talks
to a running module via real NATS, the same way a production client
would.
# 1. Bring up the dev stack (FS + NATS in docker)
docker compose -f docker-compose.dev.yaml up -d
# 2. Build + run the suite. run.sh probes the broker and the module
# before each test so failures are unambiguous.
cd tests
make
MOD_EVENT_AGENT_URL=nats://localhost:7001 \
MOD_EVENT_AGENT_NODE_ID=fs-audit \
./run.shThe four test binaries:
| Binary | Asserts |
|---|---|
test_size_limit |
The 64KB payload cap rejects oversized requests with the Payload too large message. |
test_denylist |
If MOD_EVENT_AGENT_API_DENYLIST is set, every verb in it is blocked with the canonical message. If unset, the test SKIPs after sanity-checking that uptime/version/status still go through (transparent-bridge mode). |
test_validation |
11 invalid-payload cases (parse errors, missing fields, wrong types, enum violations) each return the expected error. |
show_modules_test |
Legacy quick-check; sends show modules and prints the response. |
Last verified run against freeswitch-events-agent Docker image:
test_size_limit : ALL PASS (3/3 cases)
test_denylist : SKIP (transparent-bridge mode) β or ALL PASS (N/N denied verbs) when env set
test_validation : ALL PASS (11/11 cases)
CI consumers should make check from tests/ β run.sh exits
non-zero on any failure.
What the integration suite verifies today vs the original roadmap:
| Surface | Status | Where |
|---|---|---|
Generic API commands (status, version, uptime) |
β Verified | test_denylist.c (3 allowed verbs) |
Generic API command global_getvar |
Trivial to add β same shape as version |
|
Typed validators (originate, hangup, dialplan.*) |
β Verified | test_validation.c (11 cases) |
Successful originate round-trip (real call) |
β Not in suite | Needs a SIP endpoint to dial; deferred |
| Async fire-and-forget delivery | No explicit assertion | |
| Event streaming (channel.* events) | β Not in suite | Needs a sourced event in FS β deferred |
| Performance benchmarks (throughput, latency) | β Not in suite | Numbers in this README come from earlier ad-hoc runs |
| Multi-node filtering (broadcast vs direct) | Broadcast <prefix>.api filter not asserted |
|
| Concurrent clients (race conditions) | β Not in suite | Single-threaded smoke today |
| Hardening guards (size cap, denylist, validators) | β Verified | New: test_size_limit.c, test_denylist.c, test_validation.c |
Cluster failover (multi-URL via SetServers) |
β Smoke verified | Module loads + responds when MOD_EVENT_AGENT_URL carries multiple URLs |
The driver detects a comma in MOD_EVENT_AGENT_URL and switches from
natsOptions_SetURL to natsOptions_SetServers. libnats then handles
connect-and-failover across the list automatically.
# Single broker (the common case)
MOD_EVENT_AGENT_URL=nats://nats:4222
# Cluster (libnats picks one and falls over to the others on disconnect)
MOD_EVENT_AGENT_URL=nats://nats-1:4222,nats://nats-2:4222,nats://nats-3:4222- The module accepts up to 16 servers in the list.
- Whitespace around commas is trimmed (so
"a, b"works). - One module log line at INFO confirms how many servers libnats was
configured with:
[mod_event_agent] NATS configured with N servers (cluster failover).
See docs/ROADMAP.md for details on implementing new drivers.
- Copy template:
cp src/drivers/driver_nats.c src/drivers/driver_mydriver.c - Implement interface: Complete all
event_driver_tmethods - Add to Makefile: Add
WITH_MYDRIVER=yesflag - Testing: Create tests in
tests/ - Documentation: Update docs/ROADMAP.md
typedef struct event_driver {
// Initialization
switch_status_t (*init)(const char *url, const char *node_id);
// Cleanup
void (*shutdown)(void);
// Commands (request-reply)
switch_status_t (*subscribe_commands)(command_callback_t callback);
switch_status_t (*send_command_response)(const char *reply_subject,
const char *json_response);
// Events (pub/sub)
switch_status_t (*publish_event)(const char *subject,
const char *json_payload);
// Health check
switch_bool_t (*is_connected)(void);
} event_driver_t;-
docs/API.md: Complete API reference
- JSON payload formats
- Available commands (sync/async)
- Response codes
- Usage examples
-
docs/ROADMAP.md: Drivers roadmap
- Current status of each driver
- Implementation guides
- Contributions
-
example/README.md: Web interface example
- Vanilla JS implementation
- Node.js native server
- Real-time call control
- FreeSWITCH: https://freeswitch.org/
- NATS: https://nats.io/
- NATS C Client: https://github.com/nats-io/nats.c
- Issues: https://github.com/zenozaga/mod_events_agent/issues
- Documentation: docs/
- Web Interface: example/
MIT License - See LICENSE for details.