# Built-in nodes

> Register a SimplePrompt, ChatCompletion or ToolExecutor trait object, or a plain closure, as a task node without writing a type.

You end with a model call, a shell command and a reusable closure each running as a task node, and a rule for when to write your own `TaskNode` instead. Swiftide implements `TaskNode` for `Arc<dyn ..>` and `Box<dyn ..>` trait objects of `SimplePrompt`, `ChatCompletion` and `ToolExecutor`, and for the `SyncFn` and `AsyncFn` closure wrappers.

The snippets use the OpenAI client and the local tool executor, so enable those feature flags next to `swiftide-tasks`:

```sh
cargo add swiftide@0.34 --features swiftide-tasks,swiftide-agents,openai
```

## Prompt nodes

A closure builds a `Prompt`, and the model client answers it:

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

let ask = task.register_node_fn(|topic: &String| {
    Ok::<_, NodeError>(
        Prompt::from("Explain {{ topic }} in two sentences.")
            .with_context_value("topic", topic.as_str()),
    )
});

// The cast turns the concrete client into a node: `Prompt` in, `String` out.
let answer = task.register_node(Arc::new(openai) as Arc<dyn SimplePrompt>);

task.starts_with(ask);
task.register_transition(ask, move |prompt| answer.transitions_with(prompt))?;
task.register_transition(answer, task.transitions_to_finish())?;

if let TaskRunOutcome::Completed(text) = task.run("a task node").await? {
    println!("{text}");
}
```

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

The closure fills a template and returns the `Prompt`. The next node is the model client itself.

The cast matters. Swiftide implements `TaskNode` for `Arc<dyn SimplePrompt>` and `Box<dyn SimplePrompt>`, not for each client type. `register_node(openai)` on its own does not compile, because the concrete client is not a node.

A `SimplePrompt` node has `Input = Prompt`, `Output = String` and `Error = LanguageModelError`. Each evaluation clones the input `Prompt` and calls `prompt` on the client, which renders the template. See [Prompts and templates](/models/prompts-and-templates/) for building the `Prompt`.

One `Arc<dyn SimplePrompt>` can back several nodes, as in the `examples/tasks.rs` excerpt in [Nodes and transitions](/tasks/nodes-and-transitions/#transitions).

The workspace-architect showcase uses the same pattern. Its join node builds one `Prompt` from every crate summary, and a `SimplePrompt` node renders it into an `ARCHITECTURE.md` draft. In the approved run from `OUTPUT.md`, the render step took 28 seconds for an 11 KB draft. The model was `claude-sonnet-5-5`, called through Chat Completions on an OpenAI-compatible gateway (Bifrost in front of AWS Bedrock):

```text
[  40.5s] join     10 summaries; 230s of agent work in 41s wall-clock (5.7x)
[  40.5s] render   drafting ARCHITECTURE.md
[  68.8s] review   draft ready (11210 bytes), waiting for approval
```

## Chat completion nodes

When a step needs a system message, a full message history or tool specs, use a `ChatCompletion` node instead:

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

let ask = task.register_node_fn(|question: &String| {
    Ok::<_, NodeError>(ChatCompletionRequest::from(vec![
        ChatMessage::new_system("Answer in one sentence."),
        ChatMessage::new_user(question.clone()),
    ]))
});

let chat: Arc<dyn ChatCompletion> = Arc::new(openai);
let complete = task.register_node(chat);

let answer = task.register_node_fn(|response: &ChatCompletionResponse| {
    Ok::<_, NodeError>(response.message().unwrap_or_default().to_string())
});

task.starts_with(ask);
task.register_transition(ask, move |request| complete.transitions_with(request))?;
task.register_transition(complete, move |response| answer.transitions_with(response))?;
task.register_transition(answer, task.transitions_to_finish())?;

if let TaskRunOutcome::Completed(text) = task.run("What does a task node do?").await? {
    println!("{text}");
}
```

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

An `Arc<dyn ChatCompletion>` or `Box<dyn ChatCompletion>` node has `Input = ChatCompletionRequest<'static>`, `Output = ChatCompletionResponse` and `Error = LanguageModelError`. It calls `complete` with the request.

Task payloads must be `'static`, so the request has to own its messages. `ChatCompletionRequest::from(Vec<ChatMessage>)` does that, and so does `ChatCompletionRequest::builder()` with an owned `Vec`. To keep a borrowed request, call `to_owned()` on it first.

The response is the whole `ChatCompletionResponse`. A node after it reads `message()`, which returns `Option<&str>`, or `tool_calls()`. The node does not run tools. To act on tool calls in a loop, use an agent. [Models overview](/models/overview/) covers the clients that implement `ChatCompletion`.

## Tool executor nodes

A `ToolExecutor` node runs one command. Pair it with a closure that builds the `Command`:

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

let command = task.register_node_fn(|count: &usize| {
    Ok::<_, NodeError>(Command::shell(format!("git log --oneline -n {count}")))
});

let shell: Arc<dyn ToolExecutor> = Arc::new(LocalExecutor::new("."));
let run = task.register_node(shell);

task.starts_with(command);
task.register_transition(command, move |cmd| run.transitions_with(cmd))?;
task.register_transition(run, task.transitions_to_finish())?;

match task.run(5_usize).await {
    Ok(TaskRunOutcome::Completed(output)) => {
        for chunk in output.stdout() {
            print!("{}", String::from_utf8_lossy(chunk));
        }
    }
    Ok(TaskRunOutcome::Paused) => unreachable!("no node in this task pauses"),
    Err(TaskError::NodeError(failed)) => {
        // An exit status, a spawn failure or an I/O error. The text can sit in either stream.
        if let Some(CommandError::NonZeroExit(output)) = failed.node_error.downcast_ref() {
            eprintln!("{}", output.to_string_lossy());
        }
        return Err(TaskError::NodeError(failed).into());
    }
    Err(other) => return Err(other.into()),
}
```

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

An `Arc<dyn ToolExecutor>` or `Box<dyn ToolExecutor>` node has `Input = Command`, `Output = CommandOutput` and `Error = CommandError`. It calls `exec_cmd` on the executor. `LocalExecutor` runs commands on the local machine. [Executors](/agents/executors/) covers the executors and their commands.

Build the input with `Command::shell`, `Command::read_file` or `Command::write_file`. Chain `with_current_dir` or `with_timeout` to set a directory or a time limit for one command.

Since 0.33, `CommandOutput` keeps standard output (stdout) and standard error (stderr) apart. `stdout()` and `stderr()` iterate over the chunks of one stream, in the order the executor read them. `to_string_lossy()` and `into_string_lossy()` merge both streams into one text.

A command that exits with a non-zero status does not produce output for the next node. `LocalExecutor` returns `CommandError::NonZeroExit` with the captured output, and the run fails with `TaskError::NodeError`. Its `node_error` field holds the original `CommandError`, so `downcast_ref` gets the output back, as the snippet does.

`NonZeroExit` also carries every I/O error, such as a command that fails to start or a missing file for `Command::read_file`. In that case the error text sits in a stdout chunk, not in stderr. The snippet prints both streams with `to_string_lossy()` for that reason. A command that runs past its timeout fails with `CommandError::TimedOut`, which the snippet passes on with the rest.

## Closure adapters

`register_node_fn` wraps a closure in `SyncFn`, and `register_node_async_fn` wraps one in `AsyncFn`. Both types are public. Build one yourself when you want to name the node's type, store it in a struct, or register the same adapter more than once:

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

let double = SyncFn::new(|n: &i32| Ok::<_, NodeError>(n * 2));
let first = task.register_node(double.clone());
let second = task.register_node(double);

task.starts_with(first);
task.register_transition(first, move |n| second.transitions_with(n))?;
task.register_transition(second, task.transitions_to_finish())?;

assert_eq!(task.run(3).await?, TaskRunOutcome::Completed(12));
```

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

`SyncFn::new` takes a closure `Fn(&I) -> Result<O, E>`. `AsyncFn::new` takes a closure that returns `Box::pin(async move { .. })`. Both are `Clone`, and every `register_node` call adds a separate node.

The error type defaults to `NodeError`, but a closure still needs its error type written out; see [Closure nodes](/tasks/nodes-and-transitions/#closure-nodes).

## Custom node or built-in

Use a built-in node when the input and output types already fit the step. A prompt in and text out is a `SimplePrompt` node. A command in and its output out is a `ToolExecutor` node. Small conversions between steps are closures.

Implement `TaskNode` yourself when the step:

- takes or returns your own domain types
- calls more than one service, or calls one in a loop
- keeps state that the caller reads from outside the task
- needs structured output from a model
- should fail with an error that names its input

The showcase's `CrateAnalyst` runs an agent and returns a typed `CrateSummary`. Its `ApprovalGate` keeps a draft that the command-line interface (CLI) approves from outside. Both are custom nodes. [Agents as task nodes](/tasks/agents-as-nodes/#custom-agent-node) and [Pause and resume](/tasks/pause-and-resume/#approval-gate) show them.

## Gotchas

- A `SimplePrompt` node clones its `Prompt` on every evaluation. Keep large values out of the template context if the node runs often.
- A `ToolExecutor` node runs commands in the executor's working directory. With `LocalExecutor`, a relative `with_current_dir` path resolves against that directory.
- To branch on a failed command instead of failing the run, write your own node that calls the executor and returns an enum. Node outputs must be `Clone`, and `CommandError` is not, so map the error to a type of your own first.

## Reference

- [`SimplePrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SimplePrompt.html)
- [`ChatCompletion`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ChatCompletion.html)
- [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html)
- [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html)
- [`LanguageModelError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.LanguageModelError.html)
- [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html)
- [`Command`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.Command.html)
- [`CommandOutput`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.CommandOutput.html)
- [`CommandError`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandError.html)
- [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
- [`SyncFn`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.SyncFn.html)
- [`AsyncFn`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.AsyncFn.html)
- [`Prompt`](https://docs.rs/swiftide/0.34/swiftide/prompt/struct.Prompt.html)
- [`TaskNode`](https://docs.rs/swiftide/0.34/swiftide/tasks/trait.TaskNode.html)
- [`NodeError`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.NodeError.html)

## Next

[How indexing works](/indexing/overview/)