Skip to main content

Quick start

Ten minutes, two softphones, one folder of YAML. No trunk, no carrier contract — the outside world can wait until the last step.

By the end you will have:

  • a PBX running on your machine, with a live dashboard;
  • two phones registered as extensions 100 and 101;
  • calls between them, running through a real dialplan;
  • a follow-me extension that hunts for someone to answer;
  • (optional) a carrier trunk for real inbound and outbound calls.

You'll need

ThingWhy
Go, or DockerTo build or run the binary.
Two softphonesZoiper, Linphone, Groundwire… the second one can be the app on your phone.
A terminalThe dashboard and the editor both live there.

Build it

git clone https://github.com/telqo/lyno.git
cd lyno
make build # produces ./bin/lyno

Prefer not to build? Use the container image instead — see Docker, then come back here for the configuration.

Describe your phone system

The smallest useful system is one tenant with two phones. Four small files:

configs/
lyno.yaml
tenants/
acme/
tenant.yaml
extensions/
100.yaml
101.yaml

The main file barely exists at this point — defaults cover the rest:

configs/lyno.yaml
sip:
bind_host: 0.0.0.0
bind_port: 5060

tenants_dir: tenants
timezone: Europe/Amsterdam

The tenant is where it gets real — a domain and two accounts:

configs/tenants/acme/tenant.yaml
name: acme
domains: [acme.pbx.example.com]

accounts:
- username: alice
password: "pick-something-good"
extension: "100"
display_name: Alice
- username: bob
password: "and-something-else"
extension: "101"
display_name: Bob

The domain doesn't need to exist in DNS — phones will use the PBX address as their outbound proxy, and the domain is just how Lyno knows which tenant a phone belongs to.

Finally, each extension gets a file that says what happens when it's called. For now: ring the phone for 30 seconds.

configs/tenants/acme/extensions/100.yaml
extension: "100"
name: Alice
steps:
- id: ring
type: dial
timeout: 30s
target:
kind: endpoint
endpoint: "100"

Copy it to 101.yaml, change both 100s to 101 and the name to Bob.

Allergic to writing YAML?

./bin/lyno -config configs/lyno.yaml -configure opens a terminal editor that builds this whole tree for you — pointing it at a path that doesn't exist starts from defaults. It validates before it saves, so it can't write a broken config.

Turn it on

./bin/lyno -config configs/lyno.yaml -validate

Validation reads the whole tree and reports every problem in one pass, not just the first — so one run tells you everything that's wrong. When it prints a summary instead of errors:

./bin/lyno -config configs/lyno.yaml

The dashboard appears. The Endpoints tab shows alice and bob, both offline — an account that never registered still shows up, which is how you tell "the phone is off" from "the account doesn't exist". Let's fix the offline part.

Point two phones at it

In each softphone, create an account:

FieldAlice's phoneBob's phone
Usernamealicebob
Password(from tenant.yaml)(from tenant.yaml)
Domain / SIP serveracme.pbx.example.comacme.pbx.example.com
Outbound proxyyour PBX's IP, e.g. 192.168.1.10:5060same

The domain is the tenant's, the proxy is the PBX. That split matters: the domain selects the tenant (and the auth realm), the proxy is just where the packets go. Get them backwards and the log will tell you — a registration whose domain matches no tenant is refused with a line naming the domain the phone offered.

Watch the Endpoints tab as each phone registers and goes green.

Make the first call

Dial 101 from Alice's phone. Bob's phone rings; answer it, talk to yourself, feel a little silly, hang up.

What just happened, in order: Alice's phone sent an INVITE → the source wasn't a trunk, so Lyno demanded digest auth in acme's realm → 101 matched an extension of acme → 101.yaml ran its dial step → Bob answered and the two legs were bridged. The call is now in the dashboard's history.

Now switch Bob's phone off and call 101 again. It rings for 30 seconds and then the call ends — the plan ran out of steps. Which brings us to the fun part.

Give the plan some personality

Dialplans read top to bottom, and a dial nobody answers falls through to the next step. That one rule is most of the power. Make a shared entry point that hunts:

configs/tenants/acme/extensions/200.yaml
extension: "200"
name: Support
steps:
- id: try-bob
type: dial
target:
kind: followme
stages:
- targets: ["101"] # Bob first
timeout: 10s
- targets: ["100", "101"] # then both
timeout: 20s

- id: done
type: hangup

Restart the PBX (config is read once, at startup) and dial 200: Bob's phone rings for ten seconds, then both ring together, and the first to answer wins.

From here the same file can grow office hours (when:), a spoken greeting (play — needs an 8 kHz WAV), music on hold and external numbers. The dialplan pages walk through all of it.

Connect the outside world (optional)

Everything so far worked without a carrier. When you have one, three things change in lyno.yaml — a trunk, outbound routes, and a default:

configs/lyno.yaml
sip:
bind_host: 0.0.0.0
bind_port: 5060

trunks:
- name: carrier
host: sip.provider.example
auth:
username: "12345678"
password: "from-your-provider"
registration:
enabled: true

outbound_routes:
- name: emergency
pattern: '^112$'
trunk: carrier
- name: national
pattern: '^0[1-9]\d{8}$'
strip: 1
prepend: "+31"
trunk: carrier

default_trunk: carrier
tenants_dir: tenants
timezone: Europe/Amsterdam

…and the tenant claims its number and decides where inbound calls land:

configs/tenants/acme/tenant.yaml (additions)
dids:
numbers: ["+31201234567"]

caller_id:
number: "+31201234567"
name: Acme

inbound_routes:
- did: "+31201234567"
destination: {type: extension, target: "200"}

Restart, and the Trunks tab shows the carrier registering and — under INBOUND FROM — the addresses Lyno discovered for it via DNS. Calls to your number now land on extension 200 and hunt for Bob; dialling 0201234567 from Alice's phone goes out as +31201234567 with acme's caller ID.

If inbound calls are refused while outbound works, the answer is almost always on that Trunks tab — see how inbound calls are recognised.

Where next