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:
git clone https://github.com/bosun-ai/swiftidecd swiftidePrerequisites
Section titled “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_URLpoints it at any OpenAI-compatible endpoint, andOPENAI_MODELpicks the model (defaultgpt-5.4). The CLI also reads.envin the current directory, but not in parent directories.cargo,sh,find,sortandgrepon yourPATH. The discover step runscargo metadata. The agent tools runfindpiped intosortthrough a shell, andgrep.- The feature flags
swiftide-agents,swiftide-tasksandopenai, plus the defaultmacros.swiftide-agentsalready turns onswiftide-tasks. - A direct
schemarsdependency with thederivefeature, for theJsonSchemaderive onCrateReport. - Swiftide 0.34 or newer: the model client sets the endpoint with
api_baseandapi_keyon theOpenAIbuilder, 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.
Task graph
Section titled “Task graph”The module comment in graph.rs sketches the graph:
discover ──fan-out──▶ analyse × N (parallel) ──▶ join (builds prompt) ──▶ render ──▶ review ──▶ write │ ▲ pause ◀──┘ └── resumeThe task is a Task<WorkspaceRoot, WrittenDocument>: a workspace root goes in, the written file comes out. It has six task nodes, registered first:
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
discoverandjoinare sync closures registered withregister_node_fn. A closure takes&Iand returnsResult<O, E>.analyseis a struct that implementsTaskNode, registered withregister_node.renderis the model client itself, registered asArc<dyn SimplePrompt>.reviewis another customTaskNode, the approval gate.writeis an async closure registered withregister_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
Section titled “Per-crate agents”Every fan-out branch evaluates the same analyse node with a different crate. Each evaluation builds a fresh agent:
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:
#[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.
Structured report
Section titled “Structured report”The agent finishes by calling a custom stop tool whose arguments must match CrateReport:
/// 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.
Task node
Section titled “Task node”CrateAnalyst implements TaskNode by running the agent and reading the report from its stop reason:
/// 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.
Parallel fan-out
Section titled “Parallel fan-out”The discover transition turns the crate list into one branch per crate:
// 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).
Join and render
Section titled “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:
// 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:
/// 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:
# ArchitectureOne short paragraph: what the workspace is for and how it is split into crates.
## Bird's-eye viewTwo or three paragraphs describing the layers (for example foundation, building blocks, integrations, facade) and which crate a user of the project depends on.
## Crate mapA 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 graphCopy 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 flowThe 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 pointsOne 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 concernsShort, 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.
Pause and resume
Section titled “Pause and resume”The review transition either continues to write or pauses the task:
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:
/// 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:
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().
Commands
Section titled “Commands”From the swiftide repository root:
export OPENAI_API_KEY=sk-...cargo run -p workspace-architectTo document another workspace without the approval pause:
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():
cargo test -p workspace-architectOutput
Section titled “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:
[ 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.
Gotchas
Section titled “Gotchas”- Retries cost time. In the approved run a retry made
swiftide-corethe 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.
LocalExecutorsets 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
Section titled “Concept pages”- Tasks overview
- Nodes and transitions
- Fan-out and join
- Pause and resume
- Agents as nodes
- Built-in nodes
- Executors
- Stopping and structured results
- Tools