Skip to main content

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.

Quote on and off

An 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

FieldRequiredValues
actionyeson, off, toggle
confirmnoA 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

FieldRequiredValues
actionyesset, clear
modeyesalways, noanswer
collectfor settrue — required, there is nowhere else the destination could come from.
promptnoAsks for the destination.
confirmnoPlayed 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"
Nothing dispatches on it, and it blocks what does

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"}}
FieldRequiredValues
actionyesstart, stop, toggle
confirmnoA 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.

Why a refused transfer is not a 4xx

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.