Route requests with a dispatcher
A dispatcher receives an event, makes a typed decision, and queues the child
workflow that should handle it. Set type: dispatcher on the routing workflow;
keep its ordinary trigger, such as a Slack webhook. Each
child declares the dispatcher it belongs to.
Decision and dispatch steps were introduced in CLI 0.56.0. Use CLI 0.56.0 or newer and a compatible managed runtime when deploying these examples.
Route Slack mentions
Create three separate workflow files. Sync all three to the same workspace, with the exact names shown below. The dispatcher receives mentions and chooses between support and engineering; an unrelated request starts neither child.
Dispatcher: slack-intake
apiVersion: outcomeci.workflow/v1
name: slack-intake
type: dispatcher
trigger:
webhook: {uses: slack, auth: secrets.slack_signing, events: [mention]}
secrets:
slack_signing: vault:slack/signing-secret
reasoning:
route: {model: openai/gpt-6-luna}
steps:
- classify:
with: trigger
using: route
decision:
intent:
type: choice
instructions: Choose the team best suited to the Slack request.
choices:
- {value: support, description: Product help or usage questions}
- {value: engineering, description: Bug reports or requests to change code}
- {value: none, description: Anything outside these teams' work}
- support:
when: classify.intent.choice == "support"
dispatch: support-answer
with: trigger
- engineering:
when: classify.intent.choice == "engineering"
dispatch: engineering-triage
with: triggerGrant the signing secret to slack-intake. Enable its webhook in the dashboard
and use that URL as the Slack app's event request URL. Slack verification,
mention filtering, and event deduplication use the existing
provider webhook receiver.
Child: support-answer
apiVersion: outcomeci.workflow/v1
name: support-answer
trigger: {type: dispatcher, dispatcher: slack-intake}
secrets:
slack: vault:slack/bot-token
apis:
slack: {uses: slack, auth: secrets.slack}
steps:
- answer:
from: trigger
reason: >
Read the support question and reply in its Slack thread. If the answer
is not clear, ask one specific follow-up question.
can:
- slack.post: {channel: trigger.channel, thread_ts: trigger.ts}Child: engineering-triage
apiVersion: outcomeci.workflow/v1
name: engineering-triage
trigger: {type: dispatcher, dispatcher: slack-intake}
secrets:
slack: vault:slack/bot-token
apis:
slack: {uses: slack, auth: secrets.slack}
steps:
- triage:
from: trigger
reason: >
Read the engineering request and reply in its Slack thread with a
concise summary and any missing information needed to investigate it.
can:
- slack.post: {channel: trigger.channel, thread_ts: trigger.ts}Grant the bot-token secret separately to each child. Children own their own
API grants, model settings, and credentials; the dispatcher's permissions are
not inherited. The dispatcher needs only the signing secret for this example.
The with: trigger input becomes each child's trigger, preserving the Slack
channel, user, text, and ts used to reply in the original thread.
Keep groups separate
A child's dispatcher is the exact name of its parent workflow in the same
workspace. Bare trigger: dispatcher is invalid. For example, two independent
groups can coexist:
| Dispatcher name | Children | Each child's trigger |
|---|---|---|
slack-intake |
support-answer, engineering-triage |
{type: dispatcher, dispatcher: slack-intake} |
operations-intake |
incident-triage, access-request |
{type: dispatcher, dispatcher: operations-intake} |
Give operations-intake its own ordinary ingress trigger and type: dispatcher.
Its children explicitly name operations-intake; slack-intake cannot invoke
those children. Missing or ambiguous target names and mismatched dispatcher
membership fail without starting the child.
Only dispatcher workflows may contain decision or dispatch steps. Ordinary
child workflows can use the existing agent, model, await, and converse steps.
A dispatch target is a literal child workflow name, fixed in the parent's
workflow definition. A model chooses typed answers, and the existing when
conditions determine which declared targets run.
Decisions and results
A decision mapping contains named questions. Each question has type and
instructions:
| Question type | Additional declaration | Result |
|---|---|---|
predicate |
None | A probability |
choice |
choices: [{value, description?}, ...] |
A selected choice, probabilities, and confidence |
score |
levels: [{label, description?}, ...] |
A numeric score, probabilities, and confidence |
Choice values are strings or booleans. A question's name becomes its output key:
classify.intent.choice reads the answer to intent in the example. Each answer includes its question name and type. Answers
retain the provider's typed fields; they do not promise a free-text explanation.
A decision step has no agent workspace or tools. It accepts with input
references, using for a model profile, and the existing optional when
condition.
Use openai/gpt-6-luna or a typesafe/ model profile for a decision.
OpenAI currently supports only gpt-6-luna on the Decisions endpoint; other
OpenAI models are rejected during workflow validation. To use Jev, declare
an explicitly granted Vault API key:
secrets:
typesafe: vault:typesafeai/api-key
reasoning:
route: {model: typesafe/jev-latest, key: secrets.typesafe}Create this key under the Vault service typesafeai; the typesafe service
name is also accepted. The model ID keeps the LiteLLM typesafe/ prefix
regardless of which Vault service name you use. Grant that secret to the
dispatcher. TypeSafe models are available for decision
steps; they are not supported as chat/model steps or policy reviewers. OpenAI
profiles use the existing key: secrets.<name> option, or the cloud's metered
provider key when no key is declared.
Dispatch is asynchronous
Each dispatch step takes one with reference and queues one child run. Its
result contains run_id, workflow_id, workflow_revision_id, and
status: queued. The parent continues immediately; a completed parent does not
mean its children have completed successfully. Inspect each child run for its
own outcome.
Use several conditional dispatch steps to fan out to several children. When no condition matches, no child is queued. Repeating the same accepted dispatch operation returns the original child receipt; changing its input conflicts. The accepted child revision stays pinned. Cycles and excessive nesting are rejected. A local run cannot queue children without a managed dispatch client.
Decision answers and dispatch receipts are retained in the parent's
run artifacts. policy.json continues
to record permission reviews and grant denials separately.