Open Source VoIP & ICT Solutions for Businesses Worldwide

We published two npm packages today. asterisk-ami-node speaks the Asterisk Manager Interface, freeswitch-esl-node speaks the FreeSWITCH Event Socket, and neither one pulls a third party dependency into your project. Both are MIT licensed, ship TypeScript types, work from ESM and CommonJS, and need nothing newer than Node 18.

Diagram showing asterisk-ami-node and freeswitch-esl-node sitting between a Node.js app or pbx-mcp and the Asterisk and FreeSWITCH switches

Why we published them

The Asterisk side of npm has gone quiet. asterisk-ami-client last shipped a release in 2018. asterisk-ami-connector last shipped in 2016. Between them they still pull around 24,000 downloads a month, which tells you people keep reaching for a Node AMI client and keep landing on code nobody has touched in seven years. That is not a criticism of the authors. Maintaining something for free is hard and stopping is allowed. It is just a gap.

The FreeSWITCH side is healthier. modesl, esl and drachtio-modesl are all in real use and one of them saw a release this year. We’re not filling a hole there, we’re offering an alternative, so pick on the merits rather than on the release date.

Both clients already existed. They were sitting inside pbx-mcp, the MCP server we released that gives an AI assistant a read-only window into both platforms. If all you want is to talk to a switch from your own Node code, dragging an MCP SDK along for the ride makes no sense. So we pulled the protocol layer out and shipped it on its own.

Talking to Asterisk

import { AmiClient, listRows } from "asterisk-ami-node";

const ami = new AmiClient({
  host: "127.0.0.1",
  username: "admin",
  password: "secret",
});

await ami.connect();

const reply = await ami.action({ Action: "CoreShowChannels" });
for (const row of listRows(reply, "CoreShowChannel")) {
  console.log(row.Channel, row.CallerIDNum, row.Duration);
}

ami.close();

The part that’s easy to get wrong by hand is the boundary between an action reply and the event stream. A list action such as CoreShowChannels answers with a success line, then one event per channel, then a completion event. Those per-channel events look exactly like unsolicited events, so a naive client hands them to your Hangup and Newchannel listeners as well as to the caller who asked for them. This client keeps them apart. Rows raised by your own action go back to you and never reach your event handlers.

CLI output works too. Asterisk 14 and later return it as a repeated Output header, which command() folds into one string:

const out = await ami.command("pjsip show endpoints");

TLS, a keepalive that notices a dead socket on an idle session, and auto-reconnect with backoff are all there as options. All three are off until you ask.

Talking to FreeSWITCH

import { EslClient } from "freeswitch-esl-node";

const esl = new EslClient({
  host: "127.0.0.1",
  password: "ClueCon",
  subscribe: ["CHANNEL_ANSWER", "CHANNEL_HANGUP_COMPLETE"],
});

esl.on("CHANNEL_ANSWER", (e) => console.log("answered", e["Unique-ID"]));

await esl.connect();
console.log(await esl.api("sofia status"));

Long jobs get their own path. api() holds the socket until the switch answers, which on an originate can be most of a minute with nothing else getting through. bgapi() hands the work to FreeSWITCH, then resolves later when the matching BACKGROUND_JOB event arrives, correlated by a job ID the client generates for you. That event gets consumed rather than passed on, so it won’t turn up in your own handlers.

Two bugs that shaped the code

Both packages carry fixes for problems we hit for real while building pbx-mcp. They’re worth describing, because if you write your own client you’ll meet both.

The one that lies to you

An ESL reply is a header block, a blank line, then a body whose size is declared in Content-Length. The obvious implementation emits a frame the moment it sees that blank line. It works perfectly until you run show channels on a busy switch, the body spans several TCP segments, and you get a confidently truncated answer with no error attached to it.

Diagram comparing an ESL client that cuts the frame at the blank line and truncates the body against one that waits for the full declared Content-Length

Twelve channels looks exactly like twelve channels whether or not there were really ninety. This client reads the declared length and waits for the whole body, counted in bytes rather than characters so a multibyte caller ID can’t shift the boundary. There’s a test that feeds a 400 row response through in 500 byte chunks.

The one that leaks

On the AMI side, the internal handler that watches for an action’s reply originally answered a yes or no question: is this message mine? That can’t express the state you actually need, which is “this row belongs to my action but I’m not finished yet”. So mid-list rows came back as “not mine” and got re-emitted into the public event stream. A test caught it. In production it would have shown up as duplicate channel events that nobody could trace.

The fix was to give the handler three answers instead of two: ignored, claimed, or done. Small change, and the class of bug it removes is the kind you spend a day chasing.

Line breaks are rejected, not stripped

AMI has no escaping. A value carrying a carriage return or newline ends the current action early, and everything after it gets read as a second action. A channel name built from user input can turn an Originate into a Command.

await ami.action({ Action: "Originate", Channel: "PJSIP/1\r\nAction: Command" });
// AmiError: Refusing to send AMI field "Channel":
// line breaks in a value can inject a second action

Throwing is deliberate. Quietly stripping the break would send an action you never wrote, and you’d have no way to know. The ESL client applies the same rule to command text.

Install them

npm install asterisk-ami-node
npm install freeswitch-esl-node

Nothing new goes on the PBX. On Asterisk you need a manager account in manager.conf. On FreeSWITCH the event socket is already running with the default password. Keep ports 5038 and 8021 bound to localhost and reach them over a tunnel or a private network, because anyone who can open those ports owns the switch.

Where the code lives

Both repositories are public: asterisk-ami-node and freeswitch-esl-node. The packages are on npm under the same names. Each README covers PBX side setup, the full option table and worked examples.

They join the rest of our open source work, which you can browse on our projects page. The same protocol groundwork sits underneath ICTCore and the products built on it, including ICTPBX and ICTContact.

FAQ

Do these replace asterisk-ami-client or modesl?

They’re not drop-in replacements. The APIs differ, so switching means a small rewrite of your call sites. If your current client works, there’s no urgency. If you’re starting fresh, or you’re stuck on something the old package won’t fix, these are maintained and the issue tracker is open.

Do I need pbx-mcp to use them?

No. They’re standalone packages with no knowledge of MCP. pbx-mcp is one consumer of the same protocol work, not a requirement.

Do they work with CommonJS?

Yes. Both ship an ESM build and a CommonJS build, so import and require both resolve to the right one automatically. TypeScript declarations come with each build.

Does the Asterisk client support TLS?

Yes. Set tls: true and the socket is wrapped, which matches tlsenable=yes on the Asterisk side. Certificate verification is a separate option, off by default because self-signed certificates on internal switches are common.

Does the FreeSWITCH client do outbound mode?

Not yet. Inbound mode is implemented, which is what you want for monitoring and control. Outbound mode, where FreeSWITCH connects out to you from the dialplan, is a different shape of problem. Open an issue if you need it and we’ll look at it.

How do I test my code without a PBX?

Both packages ship their own test suites that run against mock TCP servers, so you can read those for a pattern. Sixteen tests on the Asterisk side, twenty on the FreeSWITCH side, and no live switch needed for any of them.