A dispatch rule routes an inbound call to a trunk and, optionally, to a voice agent. Rules live in the 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 lowest priority 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_id set — the rule is scoped to that one trunk. The list shows the trunk’s display name in the To trunk column.
  • trunk_id null — 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.
The control plane does not treat a global rule differently at write time: it is stored in the same hash, ordered in the same sorted set by the same 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. A phone_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:
  1. per-DID rule
  2. trunk rule
  3. park
That means a per-DID override can silently beat the agent a trunk-level rule would have chosen. Because the trunk screen and the number screen each only see their own half, use the Effective inbound routing view (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 toggles enabled 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:
  1. Update the row by id (scoped to org_id), or insert a new one.
  2. Build the engine payload from the saved row, not the submitted form.
  3. 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.
  4. If the saved rule is disabled — delete the rule id from both of those keys, so the engine never matches it.
  5. 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_number with status = active, written to the DID hash keyed by e164 and carrying agent_id, trunk_id, rate_plan_id and status;
  • every dispatch_rule with enabled = true, written to the dispatch-rule hash and scored into the order set by priority.
It returns { 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.
A save that succeeds but whose resync fails is treated as best-effort and the modal closes; if a new rule does not take effect, re-sync before assuming the patterns are wrong.

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 queries agentVersions.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.

Managing rules over the admin API