Skip to content
Daniel Hokanson edited this page Aug 30, 2026 · 2 revisions

forge-voice is a small self-hosted telephony sidecar for Forge. It wraps Asterisk behind a handful of HTTP endpoints so a Forge install can place calls on its own SIP trunk and get finished calls logged as communication records — without the main application ever carrying a SIP stack, RTP, codec negotiation or voicemail routing.

It is deliberately the smallest thing that can be called a bridge: one Node file, four Asterisk config files, a Compose stack of two containers. It is not part of the Forge deployment stack, it holds no Forge data, and nothing in Forge requires it.

What it does, and what it does not

The name causes a recurring misunderstanding, so state it plainly: this is call control, not speech. There is no speech recognition here, no transcription, no text-to-speech, no voice command surface, no IVR beyond a two-line dialplan. Forge's voice-assistant and TTS features are unrelated code living in forge-api.

State
Outbound call placement Implemented — an HTTP POST becomes an ARI Originate on the trunk
Inbound call reporting Implemented — a call arriving on the trunk enters the Stasis app and is reported to Forge
Call-state callback to Forge Implemented — authenticated by a shared secret, terminal states become communication records
Call recording Off, and off deliberately. asterisk/extensions.conf carries a commented MixMonitor template with the disclosure playback above it. Consent law varies by jurisdiction; enabling it is a per-install legal decision, not a toggle
Transcription / STT / TTS Not here at all. Forge's ingestion path accepts a transcript field, but nothing in this repo produces one
Voicemail drop, power dialer, IVR menus Not implemented

src/server.js is the shape of a bridge and says so in its own header comment. The Originate endpoint string and the dialplan usually need tuning for a given trunk's endpoint naming and caller-ID rules. Treat it as a starting point you adapt, not a finished product you configure.

Where it sits

forge-ui (browser)  ──POST /api/voice/call──▶  forge-voice  ──ARI──▶  Asterisk  ──SIP──▶  trunk ──▶ PSTN
                                                    │
forge-api  ◀──POST /api/v1/voice/webhook────────────┘   (call-state events)

The split is the point. Call control is open source and self-hosted; the SIP trunk is a commodity you buy per minute. Any provider that speaks plain SIP works, and changing provider is a credentials change rather than a rewrite. That is the whole argument against wiring Forge to a proprietary voice API — Forge does ship a Twilio webhook path, but that is Twilio's Programmable Voice, a different product from the SIP Trunking one that would sit perfectly well under this repo.

The seam with forge-api

One endpoint on the API side carries the whole integration: POST /api/v1/voice/webhook, in VoiceController.cs. The bridge posts call-state JSON there with an X-Forge-Voice-Secret header; the controller compares the secret in fixed time, ignores non-terminal states, and hands terminal ones to a channel-agnostic ingestion handler that also serves inbound email and the Twilio path. A call and an email land as the same kind of record — the party is resolved from the phone number, the duration is stored, and a transcript, if one ever exists, is hashed as its own artifact separately from any audio.

Two things gate that endpoint from the API side, and both are off on a fresh install:

  • CAP-EXT-VOIP-SYNC must be enabled. The controller carries a capability attribute, so with the capability off the route refuses the call. See Capability Gating on the hub for what that means and how the refusal looks.
  • Voice:WebhookSecret must be configured on the API. Unset, the endpoint answers 503 rather than defaulting open — an install that never configured a secret has not opted into accepting call events, and anything that could post there unauthenticated could fabricate a record the system treats as evidence.

The bridge mirrors that posture: with no secret configured it logs an error and posts nothing, rather than hammering a rejecting endpoint.

The forge-ui side is not wired today

This is the most important honest caveat in this wiki, and it contradicts this repo's own README.

forge-ui ships an AsteriskOutboundService that posts to this bridge's /api/voice/call, alongside the default TelLinkOutboundService that just emits a tel: link and places no call. The README tells you to point the OutboundCallService alias at the Asterisk implementation. That alias is not what the application injects. The one shipped consumer — the leads queue's click-to-dial button — imports and injects TelLinkOutboundService by its concrete class, so flipping the alias changes nothing. Two further gaps sit behind that one: voiceServiceUrl does not exist in any environment*.ts file (the service defaults its base URL to an empty string and reports itself unavailable), and the bridge runs no CORS middleware, so a cross-origin browser POST to it would not survive preflight in any case.

What that adds up to: inbound call logging works end to end; browser-initiated outbound dialling does not, without changes on both sides. Outbound origination itself works fine when driven server-side or by hand against the bridge's endpoint. If you intend to wire the SPA path, plan on a small forge-ui change plus reverse-proxying the bridge under the SPA's own origin — see Setup.

Advertised capability flags on the UI service overstate the bridge too: AsteriskOutboundService declares recording: true and voicemailDrop: true, and neither exists here.

HTTP surface

The bridge listens on 3030 and holds call state in a plain in-memory map, so a restart drops tracking for anything in flight.

Method Path Purpose
POST /api/voice/call Place a call. Body { phone, callerId?, context? }; returns { ok, callId, channel } once Asterisk accepts the Originate
POST /api/voice/hangup/:callId Hang up a tracked call
GET /api/voice/status/:callId Channel state for a tracked call
GET /health Liveness, plus whether the ARI connection is up

None of these endpoints authenticate. Anything that can reach port 3030 can place a call on your trunk and spend your money. Treat that as a deployment requirement, not a detail — Setup covers the exposure posture.

Known behaviour worth knowing before you rely on it

  • Only two states are ever emitted. The bridge posts on StasisStart (as Up) and StasisEnd (as Hangup). It never emits Busy, NoAnswer or Failed, even though the API understands all three as terminal.
  • An unanswered outbound call is never logged. If the Originate times out without the channel entering Stasis, no StasisEnd fires, so no event is posted — and the entry stays in the in-memory map for the life of the process. A dial-heavy install should expect that map to grow.
  • Duration is measured from the Originate, not from answer, so outbound durations include ring time. The API rounds seconds up to whole minutes.
  • An outbound call with no callerId posts a literal string as its from value. The API resolves the external party from the to number for outbound calls, so this is usually harmless, but it is not a phone number and it will look odd in logs.
  • No tests exist. CI installs dependencies, syntax-checks every JS file and builds the image. That is the whole gate, and the workflow file says as much.

Where to go next

Issues and pull requests belong on this repo; bugs in the main application go to the Forge repo. Contribution mechanics are on the hub's Contributing page.