dispatch_rule table, scoped to an org, and
are evaluated in priority order — lowest number first, first match wins.
The console lists them straight from the table
(dispatch_rule where org_id = the active org, ordered by priority) and
writes through the admin API: admin.upsertDispatchRule and
admin.deleteDispatchRule. Every write also mirrors the rule into the Redis
keys the TeleQuick engine matches on.
The dispatch rule object
The three narrowing fields are
from_pattern, to_pattern and trunk_id.
Their defaults (*, *, null) each mean “do not narrow on this”. A rule
whose fields are all left at their defaults narrows on nothing.
name and org_id stay in Postgres. The payload the control plane ships to
the engine carries only id, priority, from_pattern, to_pattern,
trunk_id, agent_id, is_global and enabled — the org is encoded in the
Redis key, and the name is a console label only.
Pattern syntax for from_pattern and to_pattern
The control plane stores both patterns verbatim.admin.upsertDispatchRule
does not normalise, canonicalise or validate them — it writes the string you
typed into Postgres and into the Redis payload. There is no E.164
normalisation step, so write the pattern in the form the call actually
presents the number.
The forms the console documents:
The console normalises only emptiness: a field left blank is trimmed and saved
as
*, never as an empty string. Because * is the “match anything” value,
clearing a pattern widens the rule rather than disabling it.
Both patterns belong to the same rule, alongside trunk_id. A call has to
satisfy every one of them that has been narrowed away from its default before
the rule matches.
Priority and first-match evaluation
Rules are evaluated from the lowestpriority value upward, and the first
rule that matches the call wins. Nothing after it is considered, so order your
rules from most specific to least specific and keep any catch-all (* / * /
any trunk) at the highest priority number.
0 is a legal priority and is the most precedent value — the console accepts
it and preserves it rather than substituting the default of 10.
Two rules may carry the same priority, but doing so leaves you without a
defined order you control. The engine reads the evaluation order from a Redis
sorted set whose members are rule ids and whose scores are priorities; rules
sharing a score fall back to ordering by rule id, which carries no routing
meaning. Give rules that could match the same call distinct priorities.
Global rules and trunk scope
trunk_id and is_global are separate controls:
trunk_idset — the rule is scoped to that one trunk. The list shows the trunk’s display name in the To trunk column.trunk_idnull — the rule is not scoped to a trunk. The list shows*.is_global— a flag on the rule, labelled in the console as “applies regardless of target trunk”. Global rules are badged global in the list.
priority,
and shipped to the engine in the same payload shape. is_global travels as a
field of that payload.
Agent assignment vs dialplan-only
agent_id is optional at the schema and API level. Leaving it unset —
”— None (dialplan only) —” in the admin and portal screens — produces a rule
that matches and routes without attaching an AI agent; the list renders the
agent column as —.
The Voice AI dispatch screen is stricter than the API. It exists to bind an
inbound number to a voice agent, so its modal refuses to save without one
(“Pick an agent to route to”) and its agent picker only offers rows from
agent_config where active is true. A dialplan-only rule created elsewhere
is still listed there; it simply has no agent to show.
Precedence against per-number bindings
A dispatch rule is not the only thing that can bind an inbound call to an agent. Aphone_number row carries its own agent_id and trunk_id, and
those per-DID bindings are mirrored into their own Redis DID hash.
The engine’s dialplan resolves inbound routing in this precedence:
- per-DID rule
- trunk rule
- park
admin.dialplanRoutes) to see what the engine will actually do: it reads the
same telequick:dialplan:rules blob the dialplan module routes on and
applies the same precedence, per trunk and per DID. It only answers for trunks
this org owns.
Enabling, disabling, and deleting
The Enabled / Disabled chip in the list togglesenabled through
admin.upsertDispatchRule — the same procedure as a full save, with the flag
flipped.
Disabling is the reversible alternative to deleting. The upsert keeps the
Postgres row and removes the rule from Redis, so the engine stops matching it
immediately while the rule stays in the list (dimmed, filterable under
Disabled) and can be switched back on. The admin suite’s Deactivate
button is exactly this: a soft delete that sets enabled to false.
admin.deleteDispatchRule is permanent. It deletes the row from Postgres and
removes the rule from both Redis keys. There is no undo; a delete from the
list is confirmed with a browser prompt only.
How a rule reaches the engine
admin.upsertDispatchRule runs in one pass:
- Update the row by
id(scoped toorg_id), or insert a new one. - Build the engine payload from the saved row, not the submitted form.
- If the saved rule is enabled — write the payload into the org’s dispatch-rule
hash under the rule id, and add the rule id to the org’s dispatch-order
sorted set scored by
priority. - If the saved rule is disabled — delete the rule id from both of those keys, so the engine never matches it.
- Rebuild the dialplan rules blob.
admin.deleteDispatchRule performs steps 1, 4 and 5 for the deleted id.
Both writes are Postgres-first. If the database write fails the procedure
throws a INTERNAL_SERVER_ERROR carrying the database message, and nothing is
pushed to Redis — the console surfaces that message in the form and the rule
you see in the list is still the rule the engine has.
Re-syncing after a failed save or delete
admin.resyncTenantRouting rebuilds this org’s inbound routing in Redis from
Postgres. It is the recovery path when a write succeeded in the database but
the push to the engine did not.
It wipes three keys for the org — the DID hash, the dispatch-rule hash, and the
dispatch-order sorted set — then repopulates them:
- every
phone_numberwithstatus=active, written to the DID hash keyed bye164and carryingagent_id,trunk_id,rate_plan_idandstatus; - every
dispatch_rulewithenabled= true, written to the dispatch-rule hash and scored into the order set bypriority.
{ ok: true, phone_numbers, dispatch_rules } — the counts it
wrote. Because it re-reads Postgres as the source of truth, anything the
database does not have (a stale agent binding, a rule you deleted) is gone
from Redis afterwards.
The Voice AI screen calls it automatically after every save and after every
delete, and reports the two failure modes separately:
- Delete failed. Nothing else runs and the row stays in the list: “Couldn’t delete this rule — … Inbound calls still match it.”
- Delete succeeded, resync failed. The row is gone from the database but the engine has not been refreshed: “Rule deleted, but the engine’s routing wasn’t refreshed … Use Re-sync to push it.” Run the re-sync again.
Routing to an unpublished agent
The engine serves only published agent snapshots. A dispatch rule pointing at an agent that has never been published will match inbound calls and then have nothing to hand them to — the calls drop, with no error on the rule itself. The Voice AI dispatch list guards against this. It queriesagentVersions.publishedAgents for the org (refreshed periodically) and badges
any rule whose agent_id is not in that set with a not published warning
tag. Publish the agent from its Versions tab to clear it.