# Workspace architect

> A typed task graph that runs one agent per crate of a Cargo workspace in parallel, joins their reports, drafts an ARCHITECTURE.md and writes it after your approval.

This showcase gives you a command-line interface (CLI) that drafts an `ARCHITECTURE.md` for any Cargo workspace. A task graph discovers the crates, runs one agent per crate in parallel, joins their reports, has a model write the document, and pauses until you approve it. Only then does it write the file.

The crate lives in the swiftide repository under `examples/workspace-architect`. Clone the repository and run the commands below from its root:

```sh
git clone https://github.com/bosun-ai/swiftide
cd swiftide
```

## Prerequisites

- Rust 1.97.1, which the swiftide workspace pins in `rust-toolchain.toml`.
- `OPENAI_API_KEY`. The CLI checks for it before any agent starts. `OPENAI_BASE_URL` points it at any OpenAI-compatible endpoint, and `OPENAI_MODEL` picks the model (default `gpt-5.4`). The CLI also reads `.env` in the current directory, but not in parent directories.
- `cargo`, `sh`, `find`, `sort` and `grep` on your `PATH`. The discover step runs `cargo metadata`. The agent tools run `find` piped into `sort` through a shell, and `grep`.
- The feature flags `swiftide-agents`, `swiftide-tasks` and `openai`, plus the default `macros`. `swiftide-agents` already turns on `swiftide-tasks`.
- A direct `schemars` dependency with the `derive` feature, for the `JsonSchema` derive on `CrateReport`.
- Swiftide 0.34 or newer: the model client sets the endpoint with `api_base` and `api_key` on the `OpenAI` builder, which arrived in 0.34.

TLS needs no setup, because swiftide's HTTP clients bring their own rustls crypto provider ([TLS](/integrations/overview/#tls)). No Docker or database is needed.

## Task graph

The module comment in `graph.rs` sketches the graph:

```text
discover ──fan-out──▶ analyse × N (parallel) ──▶ join (builds prompt) ──▶ render ──▶ review ──▶ write
                                                                                     │  ▲
                                                                            pause ◀──┘  └── resume
```

The task is a `Task<WorkspaceRoot, WrittenDocument>`: a workspace root goes in, the written file comes out. It has six task nodes, registered first:

```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` and `join` are sync closures registered with `register_node_fn`. A closure takes `&I` and returns `Result<O, E>`.
- `analyse` is a struct that implements `TaskNode`, registered with `register_node`.
- `render` is the model client itself, registered as `Arc<dyn SimplePrompt>`.
- `review` is another custom `TaskNode`, the approval gate.
- `write` is an async closure registered with `register_node_async_fn`.

`task.starts_with(discover)` sets the entry node. The excerpts below show most transitions. Three short ones in `graph.rs` sit between the excerpts: `starts_with`, the `render` to `review` transition covered under Join and render, and `task.register_transition(write, task.transitions_to_finish())`, which ends the run.

## Per-crate agents

Every fan-out branch evaluates the same `analyse` node with a different crate. Each evaluation builds a fresh agent:

```rust title="examples/workspace-architect/src/analyst.rs"
fn agent_for(
    &self,
    member: &WorkspaceCrate,
    analysis: Analysis,
) -> Result<Agent, AgentBuilderError> {
    // Every command the tools run executes inside the crate directory.
    let executor: Arc<dyn ToolExecutor> = Arc::new(LocalExecutor::new(&member.dir));
    let tools: [Box<dyn Tool>; 4] = [
        list_files(),
        read_file(),
        grep(),
        summary_stop_tool().into(),
    ];

    Agent::builder()
        .name(member.name.as_str())
        .llm(&self.llm)
        .context(DefaultContext::from_executor(executor))
        .system_prompt(SYSTEM_PROMPT)
        // Replace the default `stop` tool with one that requires a `CrateReport`.
        .without_default_stop_tool()
        .tools(tools)
        .before_tool(move |_agent, tool_call| {
            analysis.tool_called(tool_call.name(), tool_call.args().unwrap_or_default());
            Box::pin(async { Ok(()) })
        })
        .limit(MAX_TURNS)
        .build()
}
```

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

`LocalExecutor::new(&member.dir)` makes the crate directory the working directory for every command the tools run. The context wraps that tool executor with `DefaultContext::from_executor`. Branches share nothing, so they can run at the same time.

`without_default_stop_tool()` comes before `tools(..)`, because `tools` merges in the default `stop` tool when you call it. The `before_tool` hook receives `&mut Agent` and the tool call. It counts the call, and with `--verbose` it also prints a `tool` line. `.limit(MAX_TURNS)` caps one query at 30 completions.

The three tools are read-only and run their commands through the agent's executor:

```rust title="examples/workspace-architect/src/tools.rs"
#[swiftide::tool(
    description = "List the files of the crate (relative paths, build output excluded).",
    param(
        name = "dir",
        description = "Directory relative to the crate root, e.g. `.` or `src/tools`"
    )
)]
pub async fn list_files(context: &dyn AgentContext, dir: &str) -> Result<ToolOutput, ToolError> {
    let Some(dir) = CratePath::parse(dir) else {
        return Ok(CratePath::rejected(dir));
    };
    let files = run(
        context,
        format!(
            "find {} -type f -not -path '*/target/*' -not -path '*/.git/*' | sort",
            dir.quoted()
        ),
    )
    .await?;

    Ok(first_lines(&files, MAX_LISTED_FILES, "files").into())
}

#[swiftide::tool(
    description = "Read a file of the crate, 250 lines at a time.",
    param(name = "path", description = "File path relative to the crate root"),
    param(
        name = "start_line",
        description = "1-based line to start from; null to start at the top"
    )
)]
pub async fn read_file(
    context: &dyn AgentContext,
    path: &str,
    start_line: &Option<usize>,
) -> Result<ToolOutput, ToolError> {
    let Some(path) = CratePath::parse(path) else {
        return Ok(CratePath::rejected(path));
    };
    let content = context
        .executor()
        .exec_cmd(&Command::read_file(path.as_path()))
        .await?
        .into_string_lossy();

    Ok(line_window(&content, start_line.unwrap_or(1)).into())
}

#[swiftide::tool(
    description = "Search the crate's files with an extended regular expression (grep -E).",
    param(name = "pattern", description = "Regex, e.g. `pub (trait|struct) `"),
    param(
        name = "dir",
        description = "Directory relative to the crate root to search in, e.g. `.` or `src`"
    )
)]
pub async fn grep(
    context: &dyn AgentContext,
    pattern: &str,
    dir: &str,
) -> Result<ToolOutput, ToolError> {
    let Some(dir) = CratePath::parse(dir) else {
        return Ok(CratePath::rejected(dir));
    };
    let matches = run(
        context,
        format!(
            "grep -rnIE --exclude-dir=target --exclude-dir=.git -e {} -- {}",
            shell_quote(pattern),
            dir.quoted()
        ),
    )
    .await?;

    if matches.trim().is_empty() {
        return Ok("No matches.".into());
    }
    Ok(first_lines(&matches, MAX_GREP_MATCHES, "matches").into())
}
```

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

`#[swiftide::tool]` turns each async function into a constructor that returns `Box<dyn Tool>`. The `description` and `param` attributes set what the model sees. `CratePath::parse` rejects absolute paths and `..` before any command runs.

### Structured report

The agent finishes by calling a custom `stop` tool whose arguments must match `CrateReport`:

```rust title="examples/workspace-architect/src/summary.rs"
/// What an analyst agent reports about one crate when it calls `stop`.
///
/// The doc comments become field descriptions in the JSON schema the model sees, so they double
/// as instructions.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CrateReport {
    /// Two to four sentences: the problem this crate solves and the role it plays in the
    /// workspace. Describe responsibilities, not files.
    pub purpose: String,
    /// Four to eight of the most important public items a newcomer must know (traits, structs,
    /// enums, macros or functions), each formatted as "`Name`: what it is for". Use names exactly
    /// as they appear in the source.
    pub key_types: Vec<String>,
    /// One entry per workspace crate listed as a dependency in the brief. Empty if the brief
    /// lists none.
    pub depends_on: Vec<DependencyUsage>,
    /// One to three sentences on feature flags, extension points (traits meant to be
    /// implemented) and non-obvious design decisions.
    pub notes: String,
}

/// How the documented crate uses one of its workspace dependencies.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct DependencyUsage {
    /// The dependency's crate name, exactly as listed in the brief.
    pub crate_name: String,
    /// What the documented crate uses it for.
    pub usage: String,
}

/// A `stop` tool whose arguments must match [`CrateReport`].
pub fn summary_stop_tool() -> StopWithArgs {
    StopWithArgs::with_parameters_schema(schema_for!(CrateReport))
}
```

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

`StopWithArgs::with_parameters_schema` works as in the [release notes agent](/showcases/release-notes-agent/#structured-stop), here with the schema of `CrateReport`. The doc comments become field descriptions in that schema, so they double as instructions. When the model calls `stop`, the agent stops with `StopReason::RequestedByTool(_, Some(arguments))`, and the arguments are the report.

### Task node

`CrateAnalyst` implements `TaskNode` by running the agent and reading the report from its stop reason:

```rust title="examples/workspace-architect/src/analyst.rs"
/// How often an agent is told to fix a missing or malformed report before the crate fails.
const MAX_CORRECTIONS: usize = 2;

#[async_trait]
impl<L: ChatCompletion + Clone + 'static> TaskNode for CrateAnalyst<L> {
    type Input = WorkspaceCrate;
    type Output = CrateSummary;
    type Error = AnalystError;

    async fn evaluate(
        &self,
        _node_id: &DynNodeId<Self>,
        member: &WorkspaceCrate,
    ) -> Result<CrateSummary, AnalystError> {
        self.analyse(member).await.map_err(|cause| AnalystError {
            crate_name: member.name.clone(),
            cause,
        })
    }
}

impl<L: ChatCompletion + Clone + 'static> CrateAnalyst<L> {
    async fn analyse(&self, member: &WorkspaceCrate) -> Result<CrateSummary, AnalysisFailure> {
        let analysis = self.progress.analysis(&member.name);
        let mut agent = self.agent_for(member, analysis.clone())?;
        agent.query(brief(member)).await?;

        // Models occasionally answer in prose or nest the `stop` arguments; say what went wrong.
        let mut corrections = 0;
        let report = loop {
            match submitted_report(&agent) {
                Ok(report) => break report,
                Err(problem) if corrections < MAX_CORRECTIONS => {
                    corrections += 1;
                    analysis.corrected(&problem);
                    agent.query(problem.correction()).await?;
                }
                Err(problem) => return Err(problem.into()),
            }
        };

        analysis.finished(&report);
        Ok(report.reconciled_with(member))
    }
}

/// The report the agent passed to `stop`, checked against the `CrateReport` schema.
fn submitted_report(agent: &Agent) -> Result<CrateReport, ReportProblem> {
    match agent.stop_reason() {
        Some(StopReason::RequestedByTool(_, Some(arguments))) => {
            Ok(CrateReport::deserialize(arguments)?)
        }
        other => Err(ReportProblem::Missing {
            stop_reason: other.cloned(),
        }),
    }
}
```

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

`#[serde(deny_unknown_fields)]` matters here. Some models nest the report under an `arguments` key, and serde rejects that with ``unknown field `arguments` ``. The node sends the error back with `agent.query(..)` and reads the stop reason again, at most `MAX_CORRECTIONS` (2) times. `reconciled_with` then replaces the model's dependency list with the one from `cargo metadata`, keeping the model's usage notes.

## Parallel fan-out

The `discover` transition turns the crate list into one branch per crate:

```rust title="examples/workspace-architect/src/graph.rs"
// One branch per crate, all evaluating the same analyst node with a different input.
task.register_transition(discover, move |members: WorkspaceMembers| {
    let mut crates = members.crates.into_iter();
    let Some(first) = crates.next() else {
        return Transition::error("the workspace has no crates to document");
    };

    crates
        .fold(Transition::fan_out(&analyse, first), |fan_out, member| {
            fan_out.and(&analyse, member)
        })
        .concurrency_model(ConcurrencyModel::Parallel)
        .join_with(join.join())
})?;
```

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

`Transition::fan_out(&analyse, first)` creates the first branch, and `.and(&analyse, member)` adds one per remaining crate. `fan_out` needs a first target, so an empty crate list fails the run with `Transition::error` and "the workspace has no crates to document". By default only publishable crates are selected. A workspace whose members all set `publish = false` fails the same way unless you pass `--all-members`. The default concurrency model is `ConcurrencyModel::Sequential`, so `.concurrency_model(ConcurrencyModel::Parallel)` is what runs the branches at the same time. `.join_with(join.join())` turns the `FanOutTransition` into a `Transition` and names the join node that waits for the group.

The join line in the captured run, with `claude-sonnet-5-5` as the model, shows the effect: `230s of agent work in 41s wall-clock (5.7x)`.

## Join and render

The graph registers one transition for the `analyse` node. Every fan-out branch evaluates that node, so all branches use the same transition to the join. The join then hands a prompt to `render`:

```rust title="examples/workspace-architect/src/graph.rs"
// Each branch hands its `CrateSummary` to the join; the join runs once all have arrived.
task.register_transition(analyse, join.join())?;
task.register_transition(join, {
    let progress = progress.clone();
    move |prompt| {
        progress.step("render", "drafting ARCHITECTURE.md");
        render.transitions_with(prompt)
    }
})?;
```

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

Fan-out branches cannot finish the task themselves. They have to reach the join. The join runs once every branch has delivered its `CrateSummary`. `render.transitions_with(prompt)` sends the result on to the render node. The join closure calls `architecture_prompt`:

```rust title="examples/workspace-architect/src/render.rs"
/// Tera template for the document. The crate summaries are the model's only source of truth.
const ARCHITECTURE_TEMPLATE: &str = r#"You are writing ARCHITECTURE.md for a Rust Cargo workspace. The readers are Rust developers who are new to the codebase and want a mental map before they read code.

Below are per-crate summaries written by agents that read each crate's source. They are your only source of truth: do not invent crates, items, flows or relationships that are not supported by them. Crates are listed foundations first.
{% for crate in crates %}
### {{ crate.name }}
Purpose: {{ crate.report.purpose }}
Key items:
{% for item in crate.report.key_types %}- {{ item }}
{% endfor %}Depends on:
{% for dep in crate.dependencies %}- {{ dep.name }}{% if dep.optional %} (optional){% endif %}: {{ dep.usage }}
{% else %}- no workspace crates
{% endfor %}Notes: {{ crate.report.notes }}
{% endfor %}
Write the document in GitHub-flavoured Markdown with exactly these sections:

# Architecture
One short paragraph: what the workspace is for and how it is split into crates.

## Bird's-eye view
Two or three paragraphs describing the layers (for example foundation, building blocks, integrations, facade) and which crate a user of the project depends on.

## Crate map
A table with the columns `Crate`, `Responsibility` and `Start reading at`. List every crate above, in the same order. `Start reading at` names one to three key items in backticks.

## Dependency graph
Copy this diagram verbatim (it was generated from `cargo metadata`; dotted edges are optional dependencies):

{{ dependency_graph }}

After the diagram, three to five bullets on what the layering means for contributors (which crate to change for what, and what a change ripples into). Stick to what the graph and the summaries show; no generic advice.

## Data flow
The two to four most important things a user of the workspace does with it, each under its own `###` heading. For each, numbered steps from input to output that name the crates and items involved, in backticks. Skip internal plumbing.

## Extension points
One bullet per kind of extension, phrased as "To add <thing>, implement `Trait` (`crate`)" or "use `macro` (`crate`)". Only traits the summaries describe as meant to be implemented.

## Cross-cutting concerns
Short, specific bullets on feature flags, observability, error handling and test utilities. Leave out anything you cannot name concretely.

Rules: be concrete, precise and terse; prefer short sentences. Put crate and item names in backticks. Refer to an item by its name and, where useful, its crate in parentheses, e.g. `Agent` (`my-crate`); do not make up module paths. Never mention the summaries, the agents or these instructions in the document. Output only the Markdown document, without wrapping it in a code fence."#;

/// Builds the prompt for the render node from every branch that reached the join.
pub fn architecture_prompt(
    summaries: &JoinInput<CrateSummary>,
) -> Result<Prompt, serde_json::Error> {
    let crates = serde_json::to_value(summaries.iter().collect::<Vec<_>>())?;
    Ok(Prompt::from(ARCHITECTURE_TEMPLATE)
        .with_context_value("crates", crates)
        .with_context_value("dependency_graph", dependency_graph(summaries)))
}
```

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

`JoinInput<CrateSummary>` gives you `iter()`, `len()` and `is_empty()`. The prompt is a Tera template with two context values: `crates`, the serialized summaries, and `dependency_graph`. The Mermaid dependency graph is generated from `cargo metadata`, not by the model, and the template tells the model to copy it verbatim.

The `render` to `review` transition wraps the model's `String` in an `ArchitectureDraft`. `ArchitectureDraft::from_model_output` strips a code fence around the whole document, in case the model adds one.

## Pause and resume

The review transition either continues to `write` or pauses the task:

```rust title="examples/workspace-architect/src/graph.rs"
task.register_transition(review, move |decision| match decision {
    Review::Approved(draft) => write.transitions_with(draft).into(),
    Review::AwaitingApproval => Transition::pause(),
})?;
```

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

`Transition::pause()` pauses the current branch. A paused branch keeps its node, and on `resume()` that node evaluates again with the same input. The approval gate is built around that:

```rust title="examples/workspace-architect/src/approval.rs"
/// Lets a draft through once it is approved, and otherwise keeps it for review.
///
/// The graph pauses the task when the gate answers [`Review::AwaitingApproval`]. A paused branch
/// re-runs its node on `resume()`, so after [`ApprovalGate::approve`] the same draft passes
/// through. The gate is cheap to evaluate, which is why the pause sits here and not on the
/// expensive render node.
#[derive(Debug, Clone)]
pub struct ApprovalGate {
    state: Arc<Mutex<GateState>>,
    progress: Progress,
}

#[derive(Debug, Default)]
struct GateState {
    approved: bool,
    pending: Option<ArchitectureDraft>,
}

impl ApprovalGate {
    pub fn new(pre_approved: bool, progress: Progress) -> Self {
        let state = GateState {
            approved: pre_approved,
            pending: None,
        };
        Self {
            state: Arc::new(Mutex::new(state)),
            progress,
        }
    }

    /// The draft waiting for a decision, if any.
    pub fn pending_draft(&self) -> Option<ArchitectureDraft> {
        self.state().pending.clone()
    }

    pub fn approve(&self) {
        self.state().approved = true;
    }

    fn state(&self) -> MutexGuard<'_, GateState> {
        self.state.lock().unwrap_or_else(PoisonError::into_inner)
    }
}

#[async_trait]
impl TaskNode for ApprovalGate {
    type Input = ArchitectureDraft;
    type Output = Review;
    type Error = Infallible;

    async fn evaluate(
        &self,
        _node_id: &DynNodeId<Self>,
        draft: &ArchitectureDraft,
    ) -> Result<Review, Infallible> {
        let mut state = self.state();
        if state.approved {
            state.pending = None;
            self.progress.step("review", "draft approved");
            return Ok(Review::Approved(draft.clone()));
        }

        state.pending = Some(draft.clone());
        self.progress.step(
            "review",
            format_args!(
                "draft ready ({} bytes), waiting for approval",
                draft.as_str().len()
            ),
        );
        Ok(Review::AwaitingApproval)
    }
}
```

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

The first evaluation stores the draft and answers `Review::AwaitingApproval`. After `approve()`, the second evaluation lets the same draft through. Putting the pause on `render` would call the model a second time.

The CLI drives the loop:

```rust title="examples/workspace-architect/src/main.rs"
let mut outcome = task.run(root).await?;
loop {
    match outcome {
        TaskRunOutcome::Completed(written) => return Ok(Some(written)),
        TaskRunOutcome::Paused => {
            if !approve_paused_draft(&task)? {
                return Ok(None);
            }
            outcome = task.resume().await?;
        }
    }
}
```

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

`run` returns `TaskRunOutcome::Paused` instead of an output. `current_nodes::<ApprovalGate>()` returns the paused gate as `CurrentNodes::One`, so the CLI can show its draft and call `approve()` before `task.resume()`.

## Commands

From the swiftide repository root:

```sh
export OPENAI_API_KEY=sk-...
cargo run -p workspace-architect
```

To document another workspace without the approval pause:

```sh
cargo run -p workspace-architect -- --workspace ../other-repo --yes
```

| Flag                 | Default           | Description                                                                                  |
| -------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `--workspace <PATH>` | `.`               | Any directory inside the workspace to document                                               |
| `--output <PATH>`    | `ARCHITECTURE.md` | Output file, relative to the workspace root                                                  |
| `--yes`              | off               | Write the draft without pausing for approval                                                 |
| `--all-members`      | off               | Also document unpublished members such as examples                                           |
| `--model <MODEL>`    | `gpt-5.4`         | Model for the analysts and the writer (env `OPENAI_MODEL`)                                   |
| `--responses-api`    | off               | Use the OpenAI Responses application programming interface (API) instead of Chat Completions |
| `--verbose`          | off               | Print every tool call and each agent's raw report                                            |

Swiftide's tracing is off unless `WORKSPACE_ARCHITECT_LOG` is set, for example to `swiftide=debug`. The CLI ignores `RUST_LOG`, so a global cargo config that sets it does not bury the draft. The client sets `max_completion_tokens` to 16,384 through `Options`, because some gateways cap an unset limit at 4,096 tokens and the document would be cut off.

The tests run the whole graph offline with a scripted model, including the pause, approval and `resume()`:

```sh
cargo test -p workspace-architect
```

## Output

This run on the swiftide repository used `claude-sonnet-5-5` for all ten analysts and the writer, through an OpenAI-compatible gateway (Bifrost in front of AWS Bedrock) with Chat Completions. The run answered `y` at the prompt and passed `--output target/ARCHITECTURE.md`. Trimmed from `OUTPUT.md`:

```text
[   0.0s] model    claude-sonnet-5-5 via Chat Completions at http://127.0.0.1:8080/v1
[   0.0s] discover 10 crates (7 other members skipped) in /Users/timonv/projects/swiftide-wt-docs-showcases
[   0.0s] analyse  started  swiftide-core
[   0.0s] analyse  started  swiftide-langfuse
...
[  14.0s] analyse  finished swiftide-langfuse in 14.0s (6 tool calls)
[  14.3s] analyse  finished swiftide-macros in 14.3s (6 tool calls)
...
[  33.9s] analyse  retry    swiftide-core: the `stop` arguments do not match the schema: unknown field `arguments`, expected one of `purpose`, `key_types`, `depends_on`, `notes`
[  40.5s] analyse  finished swiftide-core in 40.5s (15 tool calls)
[  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
...
[  68.8s] review   draft approved
[  68.8s] write    /Users/timonv/projects/swiftide-wt-docs-showcases/target/ARCHITECTURE.md (11210 bytes)
Wrote /Users/timonv/projects/swiftide-wt-docs-showcases/target/ARCHITECTURE.md (11210 bytes) in 68.8s.
```

A second run with `--verbose` took 217s of agent work in 33s (6.5x). Writing the document took about 28 seconds in both runs. A few lines of the generated crate map:

```text
| Crate | Responsibility | Start reading at |
|---|---|---|
| `swiftide-core` | Shared traits, data types and errors for indexing, querying, chat completion, tools and agent context. No integrations. | `Node`, `Query`, `ChatCompletion` |
...
| `swiftide-tasks` | In-process typed task graph engine with transitions, fan-out/join, pause and resume, and adapters for closures and core traits. | `Task`, `TaskNode`, `Transition` |
| `swiftide-agents` | LLM agent loop over chat completions and tool calls, with hooks, a default context, a local executor and optional MCP toolboxes. | `Agent`, `AgentBuilder`, `DefaultContext` |
```

The run documents a swiftide checkout that includes `swiftide-agents-contrib`, which shipped in 0.34.

## Gotchas

- Retries cost time. In the approved run a retry made `swiftide-core` the slowest branch, and the join waits for it.
- Each correction is a new `query`, so it gets a fresh turn limit. One crate can use up to 90 completions.
- See [Turn limit and nudges](/showcases/release-notes-agent/#turn-limit-and-nudges) in the release notes agent for how a turn limit and a missing tool call share a stop reason. This node treats both as a missing report.
- `LocalExecutor` sets a working directory. It does not sandbox. The tools reject absolute paths and `..`, but symlinks inside a crate are followed. Treat the checks as a guard rail.

## Concept pages

- [Tasks overview](/tasks/overview/)
- [Nodes and transitions](/tasks/nodes-and-transitions/)
- [Fan-out and join](/tasks/fan-out-and-join/)
- [Pause and resume](/tasks/pause-and-resume/)
- [Agents as nodes](/tasks/agents-as-nodes/)
- [Built-in nodes](/tasks/built-in-nodes/)
- [Executors](/agents/executors/)
- [Stopping and structured results](/agents/stopping-and-structured-results/)
- [Tools](/agents/tools/)

## 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)
- [`Transition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.Transition.html)
- [`FanOutTransition`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.FanOutTransition.html)
- [`ConcurrencyModel`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.ConcurrencyModel.html)
- [`JoinInput`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinInput.html)
- [`JoinTarget`](https://docs.rs/swiftide/0.34/swiftide/tasks/struct.JoinTarget.html)
- [`TaskRunOutcome`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskRunOutcome.html)
- [`CurrentNodes`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.CurrentNodes.html)
- [`TaskError`](https://docs.rs/swiftide/0.34/swiftide/tasks/enum.TaskError.html)
- [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html)
- [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
- [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
- [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html)
- [`SimplePrompt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SimplePrompt.html)
- [`Options`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.Options.html)
- [`#[swiftide::tool]`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html)

## Next

[Docs Q\&A with evaluation](/showcases/docs-qa/)