# Nodes and transitions

> Register closures and TaskNode types as nodes and connect them with typed transitions from start to finish.

Here you build a linear task from two closures and a node type of your own, run it, and find out which node failed when something goes wrong.

Import the task types from `swiftide::tasks`. [Tasks overview](/tasks/overview/#feature-flag) shows the feature flag to turn on.

```rust
use std::num::ParseIntError;

use swiftide::{
    reexports::async_trait::async_trait,
    tasks::{DynNodeId, NodeError, Task, TaskError, TaskNode, TaskRunOutcome, Transition},
};
```

Source: [`snippets/src/tasks/nodes_and_transitions.rs#L4-L9`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/tasks/nodes_and_transitions.rs#L4-L9)

Register the nodes, connect them, and run the task:

```rust
let mut task: Task<String, String> = Task::new();

// A sync closure node. `Ok::<_, NodeError>` names the error type.
let trim = task.register_node_fn(|text: &String| Ok::<_, NodeError>(text.trim().to_string()));

// A node type that implements `TaskNode`.
let count = task.register_node(CountWords);

// A closure that returns a pinned, boxed future. Copy what it needs before `async move`.
let label = task.register_node_async_fn(|words: &usize| {
    let words = *words;
    Box::pin(async move { Ok::<_, NodeError>(format!("{words} words")) })
});

task.starts_with(trim);
task.register_transition(trim, move |text: String| {
    if text.is_empty() {
        return Transition::error("there is no text to count");
    }
    count.transitions_with(text).into()
})?;
task.register_transition(count, move |words| label.transitions_with(words))?;
task.register_transition(label, task.transitions_to_finish())?;

match task.run("  nodes and transitions  ").await? {
    TaskRunOutcome::Completed(summary) => println!("{summary}"),
    TaskRunOutcome::Paused => println!("paused; call task.resume() to continue"),
}
```

Source: [`snippets/src/tasks/nodes_and_transitions.rs#L36-L63`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/tasks/nodes_and_transitions.rs#L36-L63)

The run prints `3 words`.

`Task<String, String>` fixes the types at both ends. Each node declares its own input and output, so a `String` becomes a `usize` and then a `String` again.

`register_node_fn` turns a synchronous closure into a node, and `register_node_async_fn` does the same for a closure that returns a pinned, boxed future (`Box::pin(async move { .. })`). `register_node` takes any value that implements `TaskNode`. All three return a `NodeId`, the typed handle you wire the node with. `NodeId` is `Copy`, so you move it into transition closures without cloning. `id()` returns its number, assigned in registration order from 0.

`starts_with` picks the start node, and `register_transition` says where to go after a node. `transitions_with` moves on to another node with a value, and `transitions_to_finish` hands the task its output. Inside a fan-out branch, that output goes to the join instead of ending the task.

`run` returns a `TaskRunOutcome`. `Completed` carries the output. `Paused` is covered in [Pause and resume](/tasks/pause-and-resume/).

## Task nodes

A task node is a type that implements `TaskNode`:

```rust
/// Counts the words in a text.
#[derive(Clone, Debug)]
pub struct CountWords;

#[async_trait]
impl TaskNode for CountWords {
    type Input = String;
    type Output = usize;
    type Error = std::convert::Infallible;

    async fn evaluate(
        &self,
        _node_id: &DynNodeId<Self>,
        text: &Self::Input,
    ) -> Result<Self::Output, Self::Error> {
        Ok(text.split_whitespace().count())
    }
}
```

Source: [`snippets/src/tasks/nodes_and_transitions.rs#L13-L30`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/tasks/nodes_and_transitions.rs#L13-L30)

`Input` is what the node receives and `Output` is what it returns. `Error` can be any `std::error::Error + Send + Sync + 'static`; `CountWords` cannot fail, so it uses `Infallible`.

`evaluate` borrows both the node and the input. The `&DynNodeId<Self>` argument is the node's own id with its concrete type erased, and most nodes ignore it. Write the impl with `#[async_trait]`, which Swiftide re-exports as `swiftide::reexports::async_trait`.

`Input` and `Output` must implement `NodeArg`, which covers every type that is `Send + Sync + Debug + Clone + 'static`. A missing derive shows up as an unmet `NodeArg` bound. `register_node` also needs the node itself to be `Clone`.

A run does not clone your node. Every evaluation calls the same instance through `&self`, so keep per-run state out of the struct.

## Closure nodes

The workspace-architect showcase registers its nodes in one block. `discover` and `write` are closure nodes:

```rust title="examples/workspace-architect/src/graph.rs"
let discover = task.register_node_fn({
    let progress = progress.clone();
    move |root: &WorkspaceRoot| {
        let members = workspace::discover(root, settings.selection)?;
        progress.step("discover", format_args!("{members} in {root}"));
        Ok::<_, workspace::DiscoveryError>(members)
    }
});

let analyse = task.register_node(CrateAnalyst::new(llm.clone(), progress.clone()));

let join = task.register_node_fn({
    let progress = progress.clone();
    move |summaries: &JoinInput<CrateSummary>| {
        progress.joined(summaries.len());
        architecture_prompt(summaries)
    }
});

// `Arc<dyn SimplePrompt>` is a task node out of the box: `Prompt` in, `String` out.
let render = task.register_node(Arc::new(llm.clone()) as Arc<dyn SimplePrompt>);

let review = task.register_node(ApprovalGate::new(settings.pre_approved, progress.clone()));

let write = task.register_node_async_fn({
    let output = settings.output;
    let progress = progress.clone();
    move |draft: &ArchitectureDraft| {
        let output = output.clone();
        let progress = progress.clone();
        Box::pin(async move {
            let written = draft.write_to(&output).await?;
            progress.step(
                "write",
                format_args!("{} ({} bytes)", written.path.display(), written.bytes),
            );
            Ok::<_, std::io::Error>(written)
        })
    }
});
```

Source: [`examples/workspace-architect/src/graph.rs#L47-L86`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/workspace-architect/src/graph.rs#L47-L86)

- `discover` is a sync closure. `Ok::<_, workspace::DiscoveryError>(members)` pins its error type, so the `?` inside converts into it.
- `write` is a plain closure that returns `Box::pin(async move { .. })`.

The other four nodes have their own pages: [Agents as task nodes](/tasks/agents-as-nodes/), [Fan-out and join](/tasks/fan-out-and-join/), [Built-in nodes](/tasks/built-in-nodes/) and [Pause and resume](/tasks/pause-and-resume/).

A closure node needs a concrete error type. Name it with `Ok::<_, E>(..)` or a return type such as `-> Result<i32, NodeError>`, unless the closure already returns a typed `Result`, as `str::parse` does.

The closure is `Fn` and runs once per evaluation, so each future needs its own copy of the captures. That is why `write` clones `output` and `progress` before `async move`. The future may still borrow the input, here `draft`.

For reusable domain logic, implement `TaskNode` instead.

## Transitions

`examples/tasks.rs` chains a prompt node, an agent node and a second prompt node:

```rust
// Tasks are typed from input to output. This task starts with a `Prompt` and finishes with the
// final teammate-facing hand-off note.
let mut task: Task<Prompt, String> = Task::new();

// Convenience helper: `Arc<dyn SimplePrompt>` already implements `TaskNode`, so prompt-model
// steps can be registered directly without any wrapper code.
let prompt_step: Arc<dyn SimplePrompt> = Arc::new(openai.clone());
let brief_prompt_id = task.register_node(prompt_step.clone());
let handoff_prompt_id = task.register_node(prompt_step);

// Custom node: the agent step owns the stop-tool contract and returns structured task data.
let agent_id = task.register_node(BriefingAgent::new(agent));

task.starts_with(brief_prompt_id);

// Step 1: turn the incoming request into a short brief that the agent can work from.
task.register_transition(brief_prompt_id, move |brief| {
    agent_id.transitions_with(brief)
})?;

// Step 2: use the structured stop payload to drive a final LLM rendering step.
task.register_transition(agent_id, move |decision: BriefingDecision| {
    Transition::next(
        &handoff_prompt_id,
        Prompt::from(
            "Write a teammate-facing hand-off note.\n\
             Audience: {{audience}}\n\
             Summary: {{summary}}\n\
             Next steps:\n\
             {% for step in next_steps %}- {{ step }}\n{% endfor %}",
        )
        .with_context_value("audience", decision.audience)
        .with_context_value("summary", decision.summary)
        .with_context_value("next_steps", decision.next_steps),
    )
})?;

// Step 3: the rendered hand-off note becomes the task output.
task.register_transition(handoff_prompt_id, task.transitions_to_finish())?;
```

Source: [`examples/tasks.rs#L157-L195`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tasks.rs#L157-L195)

A transition closure receives the node's output by value and returns where to go next. `agent_id.transitions_with(brief)` is shorthand for `Transition::next(&agent_id, brief)`. Both return a `MarkedTransition` that carries the target's type, so the compiler checks the value against the target's `Input`.

Each node has exactly one transition, and registering a second one returns `TaskError::InvalidState`. To branch, return different transitions from that one closure. When the arms point at nodes of different types, or one arm pauses or fails, convert each `MarkedTransition` into a `Transition` with `.into()`, as the linear task does.

`Transition::error` fails the whole run, drops any in-flight fan-out branches and leaves nothing to resume. It accepts anything that converts into `Box<dyn Error + Send + Sync>`, including a `&str`. `Transition::pause()` pauses the branch.

When the decision needs to await something, use `register_transition_async`. Its closure returns a future, such as `move |value| async move { next.transitions_with(value) }`.

## Start and finish

The start node's `Input` must be the task's `Input`, or `starts_with` does not compile. `run` takes `impl Into<Input>`, so the linear task can pass a `&str` to a `Task<String, String>`.

`transitions_to_finish()` returns a closure that takes the task's `Output`. The finish constructor on `Transition` is private, so the compiler checks the value against that task's output type. A finish closure taken from a different task still compiles, and `run` then fails with `TaskError::TypeError`.

Code from 0.32 called `transitions_to_done` and got an `Option` from `run`. [Upgrading](/reference/upgrading/#tasks-crate) lists the 0.33 changes.

## Errors

Match on the result of `run` to tell a failing node from a wiring mistake:

```rust
let mut task: Task<String, u32> = Task::new();

let parse = task.register_node_fn(|text: &String| text.trim().parse::<u32>());
task.starts_with(parse);
task.register_transition(parse, task.transitions_to_finish())?;

match task.run("forty-two").await {
    Ok(TaskRunOutcome::Completed(number)) => println!("parsed {number}"),
    Ok(TaskRunOutcome::Paused) => println!("paused"),
    Err(TaskError::NodeError(failed)) => {
        // `node_error` is the error the node returned, boxed.
        if let Some(error) = failed.node_error.downcast_ref::<ParseIntError>() {
            eprintln!("node {} could not parse the input: {error}", failed.node_id);
        }
    }
    Err(wiring @ (TaskError::NoSteps | TaskError::MissingTransition(_))) => {
        // `run` checks the graph before the first node runs.
        panic!("the task is wired wrong: {wiring}");
    }
    Err(other) => return Err(other.into()),
}
```

Source: [`snippets/src/tasks/nodes_and_transitions.rs#L72-L92`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/tasks/nodes_and_transitions.rs#L72-L92)

A failing node makes `run` return `TaskError::NodeError`. The `NodeError` inside carries `node_id`, the number of the failing node, and `node_error`, the node's own error boxed. Downcast `node_error` to read your error type. The error displays as `Node error in node 0: invalid digit found in string`. A `Transition::error` produces the same variant, with the id of the node whose transition failed.

`run` and `resume` check the graph before any node runs. Without a start node you get `TaskError::NoSteps`. When any registered node has no transition, you get `TaskError::MissingTransition`, even when no path reaches that node, with a message such as `Node 3 is missing a transition`.

`TaskError::Incomplete` means all work drained without a finish or a pause.

## Reference

- [`Task`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Task.html)
- [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html)
- [`NodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeId.html)
- [`DynNodeId`](https://docs.rs/swiftide/0.34/swiftide/tasks/type.DynNodeId.html)
- [`NodeArg`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.NodeArg.html)
- [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html)
- [`MarkedTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.MarkedTransition.html)
- [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html)
- [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html)
- [`NodeError`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeError.html)

## Next

[Fan-out and join](/tasks/fan-out-and-join/)