Feature codes and transfer
Do-not-disturb, call forwarding and voicemail retrieval are ordinary dialplan
steps. A feature code is therefore an ordinary Extension document whose
metadata.name happens to start with *.
That is not a shortcut. Nothing restricts a plan's name to digits, so a plan can
answer on *78, and in doing so it gets time conditions, the editor and
validation for free.
configVersion: lyno/v1
kind: Extension
metadata:
name: "*78"
tenant: acme
spec:
description: Do not disturb on
steps:
- id: dnd
type: dnd
action: "on"
confirm: ../sounds/prompts/en/feature-dnd-on.wav
configVersion: lyno/v1
kind: Extension
metadata:
name: "*72"
tenant: acme
spec:
description: Set forwarding
steps:
- id: set
type: forward
action: set
mode: always
collect: true
prompt: ../sounds/prompts/en/feature-forward-enter.wav
confirm: ../sounds/prompts/en/feature-forward-set.wav
Where the document lives is up to you: put all the codes in one file, or keep each beside the tenant it belongs to. It carries no meaning either way.
A dialplan is also found before an account of the same number, which is what
lets *97 work while 100 still rings a phone — and what makes a plan named after
an account extension the way to give that extension voicemail.
on and offAn action is checked against a closed set (on, off, toggle for dnd), because
a misspelled one is the worst failure these steps have: the call sounds exactly the
same and nothing changes. lyno -configure writes "on" and "off" quoted so a
round trip cannot turn them into booleans. Either form loads.
dnd
| Field | Required | Values |
|---|---|---|
action | yes | on, off, toggle |
confirm | no | A WAV played once the change has been made. |
Do-not-disturb makes an extension contribute no ring targets — exactly like a phone that is not registered. The caller's plan therefore falls through to whatever comes next, through one code path rather than a special case.
It applies to ring groups, follow-me stages and queue agents alike.
forward
| Field | Required | Values |
|---|---|---|
action | yes | set, clear |
mode | yes | always, noanswer |
collect | for set | true — required, there is nowhere else the destination could come from. |
prompt | no | Asks for the destination. |
confirm | no | Played once the change has been made. |
mode: busy is refused at startup, with a reason:
forward: mode 'busy' is not supported: the PBX has no per-endpoint busy signal
That is a permanent refusal rather than a gap — nothing in the system knows an endpoint is busy, so the mode could only ever be silently dead.
forward always is applied wherever an extension is rung. forward noanswer is
applied only to a dial of exactly one endpoint; ring groups and follow-me
stages are exempt by design, since "the group did not answer" is not the same
statement as "this person did not answer".
A chain of forward always is followed at most five hops deep, and a
destination that resolves to nothing rings nobody rather than failing the call.
Collecting a destination
The caller enters up to 20 digits, terminated by #. A destination that matches
an extension of the tenant stays an extension; anything else goes out through the
outbound routes — so "forward to 101" and "forward to
my mobile" both mean what the person pressing the keys meant.
Entering nothing changes nothing. A caller who gave up did not ask for their existing forward to be cleared.
Only the caller's own state changes
Both steps refuse a call that is not from an authenticated endpoint, and they
only ever change the state of the endpoint that placed the call — never the
number that was dialled, and never the From header.
Without that, somebody who knew a customer's DID and their feature codes could point that customer's phone at a premium-rate number, and the first sign of it would be the bill.
A call that arrives over a trunk therefore cannot set a feature code. The step returns without doing anything and the plan continues.
State survives a restart
Do-not-disturb and forwarding are runtime state, not configuration. They live
in features.json at the root of the data directory
and are never written back into the files that hold SIP passwords.
Without a dataDir they still work — they just do not outlive the process, and
the PBX says so at startup:
feature codes are configured but dataDir is not:
do-not-disturb and call forwarding will not survive a restart
State belonging to an account that no longer exists is dropped at startup, so deleting an account in the editor cannot leave calls forwarding somewhere forever.
The featureCodes: block is not a dispatcher
A tenant may also carry a featureCodes: block:
kind: Tenant
metadata: { name: acme }
spec:
domains: [acme.pbx.example.com]
featureCodes:
voicemail: "*97"
dndOn: "*78"
dndOff: "*79"
The block is validated and the editor offers all eight fields. But no code in it
routes a call: tenant.FeatureCode has no caller. Dialling *78 reaches the
feature only because an Extension document is named *78.
Worse, validation refuses a feature code that collides with a dialplan
extension — so declaring dndOn: "*78" here forbids the *78 plan that is the
only working form. The two cannot coexist.
The one value read at run time is featureCodes.voicemail, and only to fill
{{.FeatureCode}} in a
voicemail notification email. queueLogin,
queueLogout and recordToggle are read by nothing at all.
Leave the block out and write the plans. If you want the retrieval code in
notification mail, name it literally in the body: template instead of using
{{.FeatureCode}}.
Recording toggle
The record step arms or disarms recording for one call:
kind: Extension
metadata: { name: "*1", tenant: acme }
spec:
steps:
- {id: rec, type: record, action: start, confirm: ../sounds/recording-on.wav}
- {id: ring, type: dial, target: {kind: endpoint, endpoint: "100"}}
| Field | Required | Values |
|---|---|---|
action | yes | start, stop, toggle |
confirm | no | A WAV played after the change. |
It arms rather than opens: the file is created when the call is bridged, so a
record step has to sit in front of the dial that creates the far leg. See
Call recording.
Blind transfer
Blind transfer needs no configuration at all. A phone in a bridged call sends
REFER; the PBX points the other party at the new destination and rebuilds the
bridge from the two survivors.
It works in both directions — the original caller can transfer, and so can a person who was dialled, which is the everyday "agent transfers the customer" case.
Attended transfer is not supported. An incoming Replaces header is stripped
and the transfer degrades to blind, with a log line saying so.
A transfer cannot send a call anywhere the tenant could not have dialled itself.
The Refer-To target must be an account extension of the tenant, another
tenant's extension where inter-tenant calling
allows it, or a number the tenant's own outbound routes accept. Anything else is
refused — otherwise a compromised phone would be a toll-fraud tool with no
credentials required.
Only one transfer runs at a time per call, and only the first resolved target is used; a transfer never forks.
The media library answers 202 Accepted and builds its client dialog before
Lyno's handler runs, so a refusal cannot be a 4xx to the REFER. It surfaces as
a failure NOTIFY on the transferor's dialog instead. If a phone reports a
transfer as accepted and then as failed, that is this.
Reference
Step schemas: Extension files → dnd, forward, record.