Multi-Task (DAG) Routing
Most assistants answer a complex request with a single AI call. Synaplan does something different: a small planner model turns the request into a DAG — a directed acyclic graph of tasks — and executes the steps in order, streaming a live task card for each one. Ask for several things at once and you get several real outputs back, on whatever channel the message arrived on.
This is the deep dive. For where it sits in the wider system (the Docker service map, SSE vs WebSocket streaming, the realtime layer) see Architecture & Realtime.
Why a plan instead of one call
A single prompt often hides several distinct jobs. "Summarise this PDF, translate the summary to German, and read it aloud" is three capabilities, not one. A flat chat completion has to fake that in one pass; a plan does each step with the right tool and lets you watch it happen.
| Single AI call | Multi-task DAG plan |
|---|---|
| One model, one answer | The right capability per step (RAG, search, media, document, calendar, …) |
| Files? Usually one, or none | Multiple generated files from one request |
| Opaque — you wait, then a wall of text | Transparent — a live card per step shows pending → running → done |
| Hard to extend | A typed capability registry you can grow |
Simple requests still take the classic fast path — one classifier decision, one handler, no planner overhead. The planner only engages for requests that the AI sorter sees as genuinely multi-step, and only when an administrator has enabled it.
A worked example
Prompt: "Can you create a short paragraph about DAG routing in AI models and create a reminder calendar entry for tomorrow at 10am?"
The planner emits a two-node plan. The chat UI shows a Task plan · 2/2 card while it runs, then leaves both results in place:
- Answer — a streamed text paragraph (
chatcapability). - Calendar invite — a downloadable
.icsmeeting file for tomorrow 10:00, with the date resolved to an absolute time (calendar_eventcapability).
┌──────────────────────────────┐
user request ──▶│ TaskPlanner (planner model │
│ → validated JSON DAG) │
└───────────────┬──────────────┘
▼
┌──────────────── DAG, executed in topological order ──────────────┐
│ n1: chat ───────────────┐ │
│ ▼ │
│ n2: calendar_event ──▶ compose_reply (terminal reply node) │
└───────────────────────────────────────────────────────────────────┘
│ live SSE cards: plan · task_update · task_chunk · task_file
▼
Answer text + meeting_YYYYMMDD_HHMMSS.ics
The relative date ("tomorrow at 10am") is resolved by injecting the current time
into the planner prompt, so the .ics lands on the right day in the right
timezone — no manual date math.
Capabilities
Each DAG node runs exactly one capability. The planner may only emit capabilities from this fixed, validated set — there is no arbitrary code execution.
| Capability | Does |
|---|---|
extract_text |
Read text from an uploaded attachment (Tika / OCR / Whisper) |
chat |
A normal text answer |
summarize |
Summarise input text |
translate |
Translate input text |
rag_query |
Semantic search over your knowledge base |
web_search |
Live web search with a generated query |
url_fetch |
Read a specific URL named in the message (robots.txt-compliant); with compare: true it keeps one saved copy and returns the diff — see Watched pages below |
mcp_fetch |
Pull live data from your connected MCP servers |
mcp_action |
Call a write-class tool on an MCP server with allow write actions on — creates a ticket or page; waits for your approval when the policy says so |
tool_call |
Call one of your custom HTTP / OpenAPI tools from the registry |
email_search |
Read-only search over your connected IMAP mailboxes and Microsoft 365 |
file_analysis |
Vision / OCR / document question-answering |
image_generation |
Generate an image (the /pic path) |
video_generation |
Generate a video (the /vid path) |
text2sound |
Text-to-speech audio |
document_generation |
Build a CSV / XLSX / DOCX / PPTX file |
document_export / document_combine |
Export to PDF or combine several documents into one (needs the office engine) |
code_run |
Short Python / Node file work on copies of chosen files — only with Secure compute on |
calendar_event |
Build an .ics calendar invite |
email_me |
Email the result to the account owner |
save_to_folder |
Upload generated files to a connected Nextcloud / OpenCloud / WebDAV folder |
condition / outbound_webhook |
Saved-task Steps only: branch on a previous result, or POST results to a URL you own |
The save_to_folder node routes by channel name: every folder you connect
under Manage → Connections → Connected apps gets a prompt-safe name (e.g. nextcloud),
and the planner places the node when you say things like "…and save it to my
Nextcloud". So "make an image of a cat and save it to Nextcloud" becomes a
two-node plan — generate, then deliver into your own storage.
A hidden compose_reply node always terminates the graph — it assembles the final
text plus any attachments into the single reply you see.
Steps pass data along the edges with a small reference grammar
($message.text, $n1.text, $n1.file, …), so one node can consume what an
earlier node produced.
The data nodes (web_search, url_fetch, mcp_fetch, email_search)
share one contract: planner-placed (never speculative), read-only, isolated
failure, timeout-bounded, and every outbound fetch goes through one shared
SSRF guard. A flag-disabled data node is omitted from the planner's catalog
entirely — the planner can't even hallucinate it.
How a plan executes
- Plan — the planner model returns JSON; Synaplan validates it (known capabilities only, valid dependencies, no cycles, a sane node cap, a valid reply node). Invalid output safely falls back to a single chat answer.
- Execute — nodes run in topological (dependency-first) order. With parallel mode enabled, independent media nodes (image/video/audio) are offloaded to concurrent subprocesses while text nodes stream inline.
- Isolate failures — if a node fails, only the steps that depended on it are skipped; the rest of the plan still delivers. If everything fails, the turn falls back to the classic single-handler path.
- Assemble & deliver — results (including multiple files) are delivered on the originating channel: chat, widget, WhatsApp, email, or webhook. Web chat additionally persists the card states, so the task plan is still there after a reload.
Live progress events (SSE)
Task plans stream over the same Server-Sent Events channel as normal answers
(/api/v1/messages/stream). Alongside the answer tokens you get plan events:
Event (status) |
Fires when | Key fields |
|---|---|---|
plan |
A multi-node plan starts | the node list (node_id, capability, kind), reply_node |
task_update |
A node changes state | node_id, state (pending → running → done / failed / skipped) |
task_chunk |
A text node streams a token | node_id, chunk |
task_file |
A node produced a file | node_id, type, url |
task_progress |
A long media render advances | node_id, percent, provider status |
See Code Examples for the SSE client pattern.
Enabling & configuring
Multi-task routing is controlled per user under Manage → Assistants → Routing (/ai/routing) in the app; administrators set the instance default under Operate → System configuration → Routing and per-group values under Operate → People → Policies.
Existing installs keep the classic single-handler fast path until it is switched
on, and a shadow mode can plan without executing so operators can review what
the planner would do before turning it loose.
Behind the scenes these map to the MULTITASK configuration group
(ROUTING_ENABLED, SHADOW_MODE, PARALLEL_ENABLED, MAX_PARALLEL,
NODE_TIMEOUT) plus the classifier's fast-path flag. The external data nodes
have their own flags: MULTITASK.URL_FETCH_ENABLED and
MULTITASK.MCP_FETCH_ENABLED + MCP.CLIENT_ENABLED ship on (seeded on
deploy, operator-overridable), MULTITASK.EMAIL_SEARCH_ENABLED ships off.
See MCP — Server & Client for the client configuration, and
docs/DEVELOPMENT.md
in the main repository for developer notes.
The routing cascade
Before the planner runs, the message has to be classified: which topic, which language, does it need web search. That decision used to be one AI call that parsed free-form JSON. It is now a cascade of layers, cheapest first, each of which may answer with confidence or defer to the next:
| Layer | Flag | Default | What it does |
|---|---|---|---|
| Fast path | CLASSIFIER.FAST_PATH_ENABLED |
off | Heuristic: trivial chat messages skip the AI sorter entirely |
| Embedding router | EMBEDDING_ROUTER.ENABLED (CONFIDENCE_THRESHOLD 0.88) |
off | Compares the message embedding against anchors for the system topics; a confident match skips the sorter |
| AI sorter, schema-enforced | STRUCTURED_OUTPUT.ENABLED |
on | The sorter, planner and extraction prompts send a JSON schema to providers that support it, so the model cannot return a malformed decision |
| Native tool routing | NATIVE_TOOL_ROUTING.ENABLED |
off | Folds "what kind of request is this?" into the answering call as tool choices — one call instead of two for plain chat |
The sorter is self-healing: when a provider rejects or garbles the schema
response, Synaplan salvages what it can, retries once with a correction, and
otherwise falls back to the general topic instead of failing the turn. All
new layers ship off; structured output is on and falls back per provider to the
previous behaviour. Routing snapshots are unchanged with the defaults.
Saved Tasks — run a plan again, on a schedule
A good plan is worth repeating. Saved Tasks turn a one-off instruction into a reusable, schedulable task:
- Save from chat — when a multi-step plan finishes, a clock icon appears on the plan card. Click it, give the task a name, and the exact instruction is pinned as a Saved Task.
- Manage centrally — all tasks live under Manage → Automations → Saved tasks: run them on demand ("Run now"), inspect the run history, or open the task's results.
- Schedule them — run a task manually, every 15/30/60 minutes, daily, or on selected weekdays. Tasks that write somewhere (email, folders) ask for an explicit unattended-run confirmation before they may run without you.
Each Saved Task gets its own chat: every run appends the incoming
instruction and the assistant's reply there, so you can always see what the last
run produced. Generated artefacts (images, documents, audio, .ics invites)
additionally land in the file manager's Generated gallery, filterable by
type. A task that fails three runs in a row is auto-paused and the owner is
notified by email.
The feature is gated by the SAVEDTASKS / ENABLED config flag — seeded on
for new installs; operators can toggle it in the admin system configuration.
The chat widget never runs Saved Tasks. Deleting a task asks for confirmation
and removes its shares; only the owner can delete it.
Watched pages — mail me what changed
A scheduled task could read a URL but could not tell whether the page had changed since last time. Watched pages closes that gap with one saved copy per address:
- In chat: "get this URL, save it, compare it to the last version and mail me
the differences". The planner emits
url_fetchwithcompare: trueplusemail_me. - Save that chat as a daily Saved Task.
- The first run mails "First copy saved."; every later run mails a unified diff, or "No changes since the last check."
The saved copies are managed on the Saved Tasks page under Watched pages:
Watch this page, View saved copy, Check now, Stop watching. One
row per owner and address — watching the same URL twice reuses it. Deleting a
Saved Task does not delete the watch; stopping a watch does not stop the task.
No history is kept (the copy is overwritten on every compare), text is capped at
64 000 characters and the diff at 200 lines. Ordinary "summarize this URL"
still returns the page, not a diff. The same MULTITASK.URL_FETCH_ENABLED
switch turns both off.
Self-hosting? Schedules only fire if the scheduler tick is running — see Quickstart → Scheduled Saved Tasks for the one container (or cron line) you need.
Extending the graph
Capabilities are a typed registry, not a hardcoded prompt: a Capability
value plus a tagged task-runner service. Today's set are thin adapters over
capabilities Synaplan already runs in production, which keeps the graph safe and
predictable.
Now shipping: open DAG endpoints via MCP. The
mcp_fetchnode hands off to your own MCP servers — n8n workflows, internal APIs, any Streamable HTTP MCP endpoint — so the planner can orchestrate the self-hosted stack you already operate. The AI does the planning; your tools do the work, on your infrastructure. Connect servers under Manage → Connections → MCP Servers and allow them per assistant — see MCP — Server & Client. (Reads run on their own; write actions need allow write actions on that server plus your approval; destructive tools are refused.)
See also
- Architecture & Realtime — where routing sits in the stack
- Code Examples — SSE streaming client
- MCP Server — expose your knowledge and memories as tools
- GitHub: synaplan — source for the planner and DAG executor