diff --git a/docs/changelog.md b/docs/changelog.md
index f457c981..349f8269 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -54,11 +54,12 @@ hide:
* **BUG** Fixed an error raised by the code in a [Python node](tech-hub/python_node.md) causing the message to fail without a reply. The participant now receives a generic error response, and the error is recorded against the message so you can see what went wrong.
## Aug 20, 2026
-* **CHANGE** Turning off a channel's **Enabled** toggle now stops new conversations and bot-initiated messages, where before it only stopped incoming ones. Previously a disabled channel still allowed new conversations to be started (from the chat widget, the public web chat link, Slack, or the chatbot management pages) and still sent out scheduled messages, event action messages and API-triggered messages. New sessions on a disabled channel are now refused, and bot-initiated messages to a disabled channel are no longer sent. Two API endpoints are still exceptions — see [Known limitations](how-to/disable_a_channel.md#known-limitations). See [Disabling a channel](concepts/channels.md#disabling-a-channel).
+* **CHANGE** Turning off a channel's **Enabled** toggle now stops new conversations and bot-initiated messages, where before it only stopped incoming ones. Previously a disabled channel still allowed new conversations to be started (from the chat widget, the public web chat link, Slack, or the chatbot management pages) and still sent out scheduled messages, event action messages and API-triggered messages.
+New sessions on a disabled channel are now refused, and bot-initiated messages to a disabled channel are no longer sent. Two API endpoints are still exceptions — see [Known limitations](how-to/disable_a_channel.md#known-limitations). See [Disabling a channel](concepts/channels.md#disabling-a-channel).
## Aug 18, 2026
* **CHANGE** When several branches merge into one node, that node now takes its input from the branch that arrived most recently, and `node_inputs` holds every input that has arrived so far instead of just one. Previously the input depended on the order the connections happened to be drawn, so the same graph could feed a merge node a different branch — any node with more than one incoming connection may now receive a different input than before. See [Which input a node receives](concepts/pipelines/parallel.md#which-input-a-node-receives).
-* **BUG** Fixed merge nodes that wait until they have a set number of inputs (for example a Python node checking `len(node_inputs)`) never completing, which made the pipeline return nothing. See [Optional Parallel Branches](concepts/pipelines/parallel.md#optional-parallel-branches).
+* **BUG** Fixed merge nodes that wait until they have a set number of inputs (for example a Python node checking `len(node_inputs)`) never completing, which made the pipeline return nothing. See [Merging branches that are optional](tech-hub/merging_parallel_branches.md#merging-branches-that-are-optional).
* **BUG** Fixed Python node code failing with `NameError: name 'enumerate' is not defined`. The `enumerate` builtin can now be used in your scripts. See [Python Node](tech-hub/python_node.md).
## Aug 17, 2026
diff --git a/docs/concepts/pipelines/index.md b/docs/concepts/pipelines/index.md
index 5df8a1e6..56375319 100644
--- a/docs/concepts/pipelines/index.md
+++ b/docs/concepts/pipelines/index.md
@@ -50,7 +50,7 @@ Common node types include:
- **[LLM Node](nodes.md#llm-node)** — Processes messages using an AI model. Handles natural conversations, answering questions, and generating responses.
- - **[Routing Nodes](nodes.md#routing-nodes)** — Makes decisions about which path the conversation should take based on the message content. Useful for directing different types of questions to different handling logic, or routing based on participant intent.
+ - **[Routing Nodes](nodes.md#routing-nodes)** — Makes decisions about which path the conversation should take, based on message content or on data already known about the participant. Useful for directing different types of questions to different handling logic, or routing on participant attributes like subscription tier.
- **[Python Node](nodes.md#python-node)** — Runs custom code to handle complex logic, fetch data from external systems, process attachments, or manipulate participant data.
diff --git a/docs/concepts/pipelines/nodes.md b/docs/concepts/pipelines/nodes.md
index acfa957e..cccb990d 100644
--- a/docs/concepts/pipelines/nodes.md
+++ b/docs/concepts/pipelines/nodes.md
@@ -50,11 +50,11 @@ See the [Render a Template and Send an Email Node](../../tech-hub/template_and_e
## Extract Structured Data Node
-Extract structured data from the input. This node acts as a passthrough, meaning the output will be identical to the input, allowing it to be used in a pipeline without affecting the conversation.
+Uses an LLM to extract structured data from the input against a JSON schema you define. Unlike most other nodes, its output **replaces** the input: downstream nodes receive the extracted data (as JSON) rather than the original conversation text.
## Update Participant Data Node
-Extract structured data and save it as participant data. This node is commonly used with [events](../events.md).
+Uses an LLM to extract structured data the same way as the Extract Structured Data node. Instead of passing the result downstream, it saves it as [participant data](../../concepts/participant_data.md). This node is a passthrough — its output is identical to its input. It can be inserted into a pipeline without changing what the next node receives, and is commonly used with [events](../events.md).
## Python Node
diff --git a/docs/concepts/pipelines/parallel.md b/docs/concepts/pipelines/parallel.md
index ccb0cd67..ee2a1b82 100644
--- a/docs/concepts/pipelines/parallel.md
+++ b/docs/concepts/pipelines/parallel.md
@@ -1,5 +1,6 @@
# Parallel Pipelines
-Nodes in a pipeline can run in parallel, allowing multiple operations to proceed simultaneously.
+
+Nodes in a pipeline can run in parallel, allowing multiple operations to proceed simultaneously. This follows directly from [how a pipeline runs](index.md#how-a-pipeline-runs): every node whose dependencies are satisfied executes in the same pass.
```mermaid
flowchart LR
@@ -9,15 +10,17 @@ flowchart LR
```
!!! warning "Limitations"
+
**Cycles**
Configurations that result in cycles (recursive loops) are not supported.
- **Multiple Exectuion**
+ **Multiple execution**
- In cases where the branches of a workflow do not have the same number of nodes and then merge, nodes after the merge will be executed more than once without special handling. See the section below on [Uneven Banches](#uneven-branches)
+ When the branches of a workflow have different lengths and then merge, the node after the merge runs more than once unless you handle this deliberately. See [Uneven branches](#uneven-branches) below.
## Dangling nodes
+
Nodes without connected outputs (dangling nodes) are supported and will execute in turn. The outputs of these nodes will still be recorded in the pipeline state.
```mermaid
@@ -30,7 +33,8 @@ flowchart LR
See this pattern used in the Workflow Cookbook: [Safety check in parallel](../../how-to/workflow_cookbook.md#safety-check-in-parallel), where an unconnected **safe** output lets compliant messages pass through unchanged.
## Multiple outputs
-Connecting multiple outputs from one node (e.g. a router node) to the output of another node is allowed. If the node produces more than one output over the course of the run, the most recent one is passed on as input — see [Which input a node receives](#which-input-a-node-receives).
+
+Connecting multiple outputs from one node (e.g. a router node) to the input of another node is allowed. If the node produces more than one output over the course of the run, the most recent one is passed on as input — see [Which input a node receives](#which-input-a-node-receives).
```mermaid
flowchart LR
@@ -51,7 +55,7 @@ flowchart LR
LLM --> out([Output])
```
-See this pattern used in the Workflow Cookbook: [Router for classification](../../how-to/workflow_cookbook.md#router-for-classification), where multiple category outputs feed into the same Python node.
+See this pattern used in the Workflow Cookbook: [Router for classification](../../how-to/workflow_cookbook.md#router-for-classification), where multiple category outputs feed into the same [Python node](nodes.md#python-node).
## Which input a node receives
@@ -88,89 +92,11 @@ The execution steps are as follows:
Notice how `NodeD` gets executed twice. The first time it runs, only `NodeB` has reached it, so `NodeB`'s output is its `input` and its single `node_inputs` entry. By the second run `NodeC` has finished, so `NodeC`'s output becomes the `input` and `node_inputs` holds both.
-To understand why this happens you need to understand the [execution model](index.md#how-a-pipeline-runs).
-
-You can manage this challenge by using a `PythonNode` with some utility functions:
-
-* `require_node_outputs`: This function will abort any node run if all the requested data is not available.
-* `wait_for_next_input`: This is a lower level function that can be used when `require_node_outputs` isn't suitable.
-
-In the example above, we could use the following code in `NodeD` to merge the outputs:
-
-```python
-def main(input, **kwargs):
- # this will abort the first run since only `NodeB` has outputs
- require_node_outputs("NodeB", "NodeC")
- b = get_node_output("NodeB")
- c = get_node_output("NodeC")
- return f"{b}\n{c}"
-```
-
-Using the lower level `wait_for_next_input` function we can do the same thing:
-
-```python
-def main(input, **kwargs):
- b = get_node_output("NodeB")
- c = get_node_output("NodeC")
- if b is None and c is None:
- # abort until both are available
- wait_for_next_input()
- return f"{b}\n{c}"
-```
-
-## Optional Parallel Branches
-
-This shows a use case for the `wait_for_next_input` function. We have a pipeline which has parallel branches and a merge node but not all the branches will execute.
-
-```mermaid
-flowchart LR
- start([Input]) --> Router
- start --> NodeA
- Router -.-> NodeB
- Router -.-> NodeC
- NodeA --> Merge
- NodeB --> Merge
- NodeC --> Merge
- Merge --> out([Output])
-```
-
-The `Merge` node will get outputs from `NodeA` and either `NodeB` or `NodeC`. We can't use `require_node_outputs` because not all outputs will be generated. Instead we need to use the `wait_for_next_input` function:
-
-=== "Option 1"
-
- ```python
- def main(input, **kwargs):
- b = get_node_output("NodeB")
- c = get_node_output("NodeC")
- b_or_c = b or c
- if not b_or_c:
- # wait until we have either b or c
- wait_for_next_input()
- a = get_node_output("NodeA")
- return f"{a}\n{b_or_c}"
- ```
-
- Note that we don't need to check if we have output from `NodeA` since it will be guaranteed to be available by the time `NodeB` or `NodeC` execute due to the execution order.
-
-=== "Option 2"
-
- This option makes use of the [`node_inputs`](../../tech-hub/python_node.md#additional-keyword-arguments) keyword argument which contains a list of all the inputs available to the current node execution. Since we want to wait until we have inputs from `NodeA and (NodeB or NodeC)` we can check that the inputs list has at least two values.
-
- ```python
- def main(input, **kwargs):
- all_inputs = kwargs.get("node_inputs", [])
- if len(all_inputs) < 2:
- # wait until we have at least two inputs
- wait_for_next_input()
- return "\n".join(all_inputs)
- ```
-
-
-
-- :material-hexagon-multiple-outline:{ .lg .middle } __More Example Workflows__
+To understand why this happens, see [how a pipeline runs](index.md#how-a-pipeline-runs).
- ---
+If `NodeD` needs to see both `NodeB` and `NodeC` before it does its real work — merging both branches exactly once, rather than running twice — write that logic in a Python node using the `require_node_outputs` or `wait_for_next_input` utility functions. The same functions handle the related case where a branch is optional and may not run at all. See [Merging Parallel Branches](../../tech-hub/merging_parallel_branches.md) for worked examples of both.
- [:octicons-arrow-right-24: Workflow Cookbook](../../how-to/workflow_cookbook.md)
+## See also
-
+- [Workflow Cookbook](../../how-to/workflow_cookbook.md)
+- [Merging Parallel Branches](../../tech-hub/merging_parallel_branches.md)
diff --git a/docs/concepts/pipelines/router_nodes.md b/docs/concepts/pipelines/router_nodes.md
index aeb8de23..2f323308 100644
--- a/docs/concepts/pipelines/router_nodes.md
+++ b/docs/concepts/pipelines/router_nodes.md
@@ -4,7 +4,7 @@
Router nodes are decision points in your pipeline. Instead of following one fixed path, a pipeline with a router can choose different paths based on what the participant says or what your system already knows about the participant.
-In simple terms, a router checks the current conversation context and sends the input (participant message plus available data) to the most relevant downstream node. This allows your chatbot to adapt in real time.
+In simple terms, a router evaluates a condition, chooses one of its configured paths, and passes the input through unchanged to the node on that path. This allows your chatbot to adapt in real time. What the router evaluates depends on its type — see [Router Types](#router-types) below.
For example:
@@ -16,7 +16,7 @@ For example:
1. **Linked Downstream Node**: Any node that appears after the current node in the pipeline flow.
-2. **Conversation Context**: The total set of information available to the pipeline at that moment. This includes the participant’s current message, their chat history (as determined by the router's own [History setting](history.md)), and known data (like whether they are a "new" or "returning" participant).
+2. **Conversation Context**: The information a router evaluates to make its decision. For an [LLM Router](#llm-router-node), this is the participant's current message and, when enabled by the [History setting](history.md), the configured conversation history. A [Static Router](#static-router-node) does not evaluate the message or history at all — it looks up a value already stored as data (see [Router Types](#router-types)).
3. **Default Path**: The "safety net" route (marked with a blue *). If the router cannot confidently decide where to send the participant, it follows this path to prevent the conversation from breaking. [Read more about the default output](../../how-to/routers/index.md#the-default-output).
diff --git a/docs/concepts/tags.md b/docs/concepts/tags.md
index 72a9bb91..74db6e8b 100644
--- a/docs/concepts/tags.md
+++ b/docs/concepts/tags.md
@@ -10,13 +10,13 @@ Tags can be created using the “Manage Tags” section.
There are 3 types of tags.
-* System tags
+### System tags
These are tags generated by the system, such as those used in multi-prompt architectures to differentiate between parent and child chatbots.
-* Session tags
+### Session tags
These tags are manually added to sessions.
-* Message tags
+### Message tags
These tags are manually added to specific messages within a participant session.

diff --git a/docs/how-to/routers/index.md b/docs/how-to/routers/index.md
index b5e9948d..4157aeab 100644
--- a/docs/how-to/routers/index.md
+++ b/docs/how-to/routers/index.md
@@ -25,7 +25,7 @@ To understand how participants move through your chatbot, you can enable Output
- [Tracing](../../concepts/tracing.md): Configure this for path-level analysis for debugging.
### Tag naming convention
-To keep system tags organized, OCS follows this naming convention:
+To keep [system tags](../../concepts/tags.md#system-tags) organized, OCS follows this naming convention:
```text
:
@@ -34,4 +34,6 @@ To keep system tags organized, OCS follows this naming convention:
Example: If you have a Router node named `support_triage` and it selects the output keyword `BILLING`, the resulting tag is:
`support_triage:BILLING`
+If the router falls back to its [Default Output](#the-default-output) — because nothing matched, or an error occurred — OCS appends `:default` to the tag, making the full form `::default`. For example, `support_triage:GENERAL:default`. This makes fallback routes easy to filter for separately when reviewing tags.
+
Ensure your `node_name` is descriptive (for example, `intent_classifier`) so tags are easy to interpret.
diff --git a/docs/tech-hub/index.md b/docs/tech-hub/index.md
index 99940cb5..77da790d 100644
--- a/docs/tech-hub/index.md
+++ b/docs/tech-hub/index.md
@@ -15,6 +15,7 @@ You need Super Admin, Pipeline, Experiment, or Team Administrator roles to acces
- **[Custom Actions](custom_action/index.md)** — Integrate external services into chatbots via OpenAPI schemas. Covers configuration, health monitoring, and testing of Custom Actions.
- **[Calling External APIs](external-api-calls/index.md)** — Use the built-in HTTP client inside Python nodes to securely call third-party APIs from a Pipeline workflow.
- **[Python Node](python_node.md)** — Write custom Python code inside a Pipeline to perform logic, process data, manage session state, and make HTTP requests to external services.
+- **[Merging Parallel Branches](merging_parallel_branches.md)** — Python node patterns for merging pipeline branches predictably, including branches that run an uneven number of times or run conditionally.
- **[Render a Template and Send an Email Nodes](template_and_email_nodes.md)** — Full Jinja2 variable reference, recipient field syntax, and examples for the Render a Template and Send an Email pipeline nodes.
- **[Tools Reference](tools.md)** — Full argument reference for all built-in tools and the LLM provider tools supported. For a conceptual overview, see [Tools Concepts](../concepts/tools/index.md).
- **[Evaluations](evaluations/index.md)** — Reference for advanced features of Evaluations — a testing system for measuring chatbot performance against different metrics.
diff --git a/docs/tech-hub/merging_parallel_branches.md b/docs/tech-hub/merging_parallel_branches.md
new file mode 100644
index 00000000..ef0c5d0c
--- /dev/null
+++ b/docs/tech-hub/merging_parallel_branches.md
@@ -0,0 +1,86 @@
+# Merging Parallel Branches
+
+You can manage a merge node running more than once, or receiving only some of its expected branches, by using a Python node with some utility functions:
+
+* `require_node_outputs`: This function will abort any node run if all the requested data is not available.
+* `wait_for_next_input`: This is a lower level function that can be used when `require_node_outputs` isn't suitable.
+
+For the execution model that causes a merge node to run more than once, and the meaning of `input` and `node_inputs`, see [Which input a node receives](../concepts/pipelines/parallel.md#which-input-a-node-receives) and [Uneven branches](../concepts/pipelines/parallel.md#uneven-branches) in Parallel Pipelines.
+
+## Merging branches that always run
+
+In the [uneven branches example](../concepts/pipelines/parallel.md#uneven-branches), you can use the following code in `NodeD` to merge the outputs:
+
+```python
+def main(input, **kwargs):
+ # this will abort the first run since only `NodeB` has outputs
+ require_node_outputs("NodeB", "NodeC")
+ b = get_node_output("NodeB")
+ c = get_node_output("NodeC")
+ return f"{b}\n{c}"
+```
+
+Using the lower level `wait_for_next_input` function you can do the same thing:
+
+```python
+def main(input, **kwargs):
+ b = get_node_output("NodeB")
+ c = get_node_output("NodeC")
+ if b is None or c is None:
+ # abort until both are available
+ wait_for_next_input()
+ return f"{b}\n{c}"
+```
+
+## Merging branches that are optional
+
+This shows a use case for the `wait_for_next_input` function. This pipeline has parallel branches and a merge node, but not all the branches will execute.
+
+```mermaid
+flowchart LR
+ start([Input]) --> Router
+ start --> NodeA
+ Router -.-> NodeB
+ Router -.-> NodeC
+ NodeA --> Merge
+ NodeB --> Merge
+ NodeC --> Merge
+ Merge --> out([Output])
+```
+
+The `Merge` node will get outputs from `NodeA` and either `NodeB` or `NodeC`. You can't use `require_node_outputs` because not all outputs will be generated — instead, use the `wait_for_next_input` function:
+
+=== "Option 1"
+
+ ```python
+ def main(input, **kwargs):
+ b = get_node_output("NodeB")
+ c = get_node_output("NodeC")
+ b_or_c = b is None or c is None
+ if not b_or_c:
+ # wait until we have either b or c
+ wait_for_next_input()
+ a = get_node_output("NodeA")
+ return f"{a}\n{b_or_c}"
+ ```
+
+ Note that you don't need to check for output from `NodeA` since it's guaranteed to be available by the time `NodeB` or `NodeC` execute, due to the execution order.
+
+=== "Option 2"
+
+ This option makes use of the [`node_inputs`](python_node.md#additional-keyword-arguments) keyword argument, which contains a list of all the inputs available to the current node execution. Since you want to wait until you have inputs from `NodeA and (NodeB or NodeC)`, you can check that the inputs list has at least two values.
+
+ ```python
+ def main(input, **kwargs):
+ all_inputs = kwargs.get("node_inputs", [])
+ if len(all_inputs) < 2:
+ # wait until we have at least two inputs
+ wait_for_next_input()
+ return "\n".join(all_inputs)
+ ```
+
+## Related pages
+
+- [Parallel Pipelines](../concepts/pipelines/parallel.md) — the execution model behind uneven and optional branches
+- [Python Node](python_node.md) — full reference for `require_node_outputs`, `wait_for_next_input`, `get_node_output`, and the other Python node utility functions
+- [Workflow Cookbook](../how-to/workflow_cookbook.md) — worked examples combining routers, Python nodes, and other node types
diff --git a/docs/tech-hub/python_node.md b/docs/tech-hub/python_node.md
index 9a53d7bc..711a67f0 100644
--- a/docs/tech-hub/python_node.md
+++ b/docs/tech-hub/python_node.md
@@ -63,6 +63,10 @@ The Python node provides a set of utility functions that can be used to interact
### ::: python_node.end_session
### ::: python_node.add_file_attachment
+!!! tip "Merging parallel branches"
+
+ For worked examples using `require_node_outputs` and `wait_for_next_input` to merge branches that run an uneven number of times, or that only sometimes run, see [Merging Parallel Branches](merging_parallel_branches.md).
+
## Debugging with print()
You can use `print()` inside your Python Node code to capture debug or diagnostic output. Any printed text is collected and stored as `console` data in the node's trace span, making it visible in the [trace detail view](../concepts/tracing.md) and in Langfuse spans if Langfuse tracing is configured.
diff --git a/mkdocs.yml b/mkdocs.yml
index d46edff3..ae09d538 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -198,6 +198,7 @@ nav:
- Tools Reference: tech-hub/tools.md
- Ending Sessions from a Chatbot: tech-hub/ending_sessions.md
- Python Node: tech-hub/python_node.md
+ - Merging Parallel Branches: tech-hub/merging_parallel_branches.md
- Render a Template and Send an Email Nodes: tech-hub/template_and_email_nodes.md
- Call External APIs:
- tech-hub/external-api-calls/index.md