Skip to content

Workspace architect

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:

Terminal window
git clone https://github.com/bosun-ai/swiftide
cd swiftide
  • 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). No Docker or database is needed.

The module comment in graph.rs sketches the graph:

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:

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

  • 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.

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

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

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:

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

#[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.

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

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

StopWithArgs::with_parameters_schema works as in the release notes agent, 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.

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

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

#[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.

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

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

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).

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:

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

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:

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

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.

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

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

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:

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

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:

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

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().

From the swiftide repository root:

Terminal window
export OPENAI_API_KEY=sk-...
cargo run -p workspace-architect

To document another workspace without the approval pause:

Terminal window
cargo run -p workspace-architect -- --workspace ../other-repo --yes
FlagDefaultDescription
--workspace <PATH>.Any directory inside the workspace to document
--output <PATH>ARCHITECTURE.mdOutput file, relative to the workspace root
--yesoffWrite the draft without pausing for approval
--all-membersoffAlso document unpublished members such as examples
--model <MODEL>gpt-5.4Model for the analysts and the writer (env OPENAI_MODEL)
--responses-apioffUse the OpenAI Responses application programming interface (API) instead of Chat Completions
--verboseoffPrint 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():

Terminal window
cargo test -p workspace-architect

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:

[ 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:

| 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.

  • 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 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.

Docs Q&A with evaluation