Home / Articles

A Local LLM Agent Workflow Built From Folders and Markdown Files

Build a gated, multi-stage local agent pipeline where each stage is a folder, prompts are editable markdown, and a human reviews output before the costly step.

Publisher: REACTAPP.TOP25 min read5861 words

This article is published in English.

Sources: Build Your Own Local LLM Agent Workflow in 400 Lines of Python

A local agent can get a repeatable, inspectable workflow with no framework involved. The setup here runs a multi-stage job in which each stage is a folder, each stage's prompt is a plain markdown file a person can read and edit, and a human reviews output between stages. The agent doesn't change; only the way its context is filed does, and that is the real point.

Why a local agent needs this

The agent already handles web search, page reading and acting through a persistent browser tab. Missing was any way to run a job repeatedly with a person checking in halfway through.

The cause was that everything steering it lived in code. One system prompt got assembled at import time, and a single tool list was built at startup and handed to the model on every turn. To make the agent play a different role in a different step, you had to edit Python. To stop and look at intermediate output before an expensive step, there was nowhere to stop. That's acceptable for a chat loop and awkward for a job with stages.

What comes out is a compact course-research pipeline. Four stages carry a single sentence describing a course to a drafted mini-course, with a review gate after every stage. Numbered folders of plain markdown now hold the context that steers the agent, so the outline can be read and corrected before drafting starts and no code is touched along the way.

The structure follows a method called Interpretable Context Methodology, or ICM, which swaps framework-level orchestration for filesystem structure.

The diagram shows the workflow as a folder called workspace. Each numbered folder under pipeline/ is a stage, each stage's CONTEXT.md is its prompt, and proposed/ is a staging area where a human promotes changes by hand.

Numbered folders act as stages, markdown files carry the per-stage context, and local scripts handle mechanical work that needs no model. The design borrows from Unix pipelines and multi-pass compilers, where one step's output feeds the next and every intermediate result is a plain file you can open.

What you need running

For the model, qwen3.5:9b runs under Ollama with thinking disabled and a temperature of 0.1. Browsing and search reuse earlier work: camofox-browser listens on port 9500 and SearXNG on 8090, each in Docker. Get those two containers running, along with Ollama and a model that supports tool calls, before starting.

The build breaks into five stages:

  • Stage 1 starts camofox-browser and SearXNG in Docker, the local services later stages call.
  • Stage 2 builds the agent's system prompt per stage out of a folder of markdown, replacing one hardcoded string.
  • Stage 3 supplies the driver, with review gates and tool limits applied per stage.
  • Stage 4 is the outline gate: one stage consumes two earlier outputs and a person signs off on the plan ahead of the pricey step.
  • Stage 5 drafts per module and adds a proposed/ folder that leaves room for later extensions.

Installing it

Each stage sits in its own subdirectory and is exposed as a console script through pyproject.toml, so after one install you can run any stage by name. The first step is cloning the repository and installing it in editable mode, using a virtual environment:

git clone https://github.com/jfjensen/local-LLM-agent-icm-workflow.git
cd local-LLM-agent-icm-workflow

python -m venv .venv

# Windows PowerShell:
.\.venv\Scripts\Activate.ps1

pip install -e .

The install brings in mcp, ollama and httpx and registers a set of console scripts. Stages 2 to 5 each get a workflow runner (mcp-workflow-stage2 through mcp-workflow-stage5) and an agent (mcp-agent-stage2 through mcp-agent-stage5). Two MCP servers, mcp-search-part3 and mcp-browser-stage5, are copied over from earlier iterations, which keeps the repository self-contained.

Configuration is in config.toml at the repository root, which gains a single new [workflow] section. Because each stage reads its workspace/ and writes its history/ relative to the current directory, it's cleanest to launch a stage from inside its own folder:

cd stage5
mcp-workflow-stage5

Stage 1: the local services

No Python is involved here. The stage starts the pair of services the agent uses during research: camofox-browser, the stealth browser the reader tools sit on, and SearXNG, the search engine under search. Anyone who already runs the earlier setup can keep the same docker-compose.yml.

cd stage1
docker compose up -d
docker compose logs -f

A few details are worth knowing:

  • Camofox listens on 9500 and SearXNG on 8090, the ports config.toml expects, so the agent finds both with no extra setup.
  • The compose file bind-mounts searxng/settings.yml, which enables the JSON API and disables the rate limiter, so search works on the first run.
  • The camofox image is built once from its upstream repository. The first build is slow because the Camoufox binary is large, and stage1/README.md lists the steps.

To check health, request http://localhost:9500/health and http://localhost:8090/search?q=ollama&format=json with curl. Both should answer with JSON.

Stage 1 is only those two services. Once camofox answers on 9500 and SearXNG on 8090, research has something to call.

Stage 2: context from files

This is the sole real code change. The agent used to carry one system prompt, a module-level string built at startup and sent unchanged on every turn no matter the task. Letting it take a different role per stage means the prompt must come from an editable place, and reading it from files is the easiest option.

So the first change lets the system prompt be overridden:

def build_messages_for_model(self) -> list[dict[str, Any]]:
    system = self.system_text if self.system_text is not None else SYSTEM_PROMPT
    return [{"role": "system", "content": system}] + self.messages

The behavior is simple:

  • With no system_text set, the agent falls back to the same hardcoded SYSTEM_PROMPT as before, so the interactive agent, mcp-agent-stage2, acts exactly as it used to.
  • When system_text is set, that becomes the system prompt for the run. The workflow runner sets it per stage, from files, and the agent class is otherwise untouched.

As for the files themselves, an ICM workspace is nothing more than a folder. stage2/workspace/ contains a CLAUDE.md describing the workspace and its layout, a CONTEXT.md listing the stages in sequence, a _config/ directory with reference material that stays fixed between runs, and a pipeline/ directory of numbered stages. Every stage directory has its own CONTEXT.md contract plus an output/ directory.

The two top-level files give identity and routing. CLAUDE.md is Layer 0, the first thing the agent reads:

# Workspace: course-research

You are a single agent operating inside an ICM workspace. This file tells
you where you are and how the workspace is laid out. Read it first.

This workspace turns a one-line course idea into a drafted mini-course,
through four stages that run one at a time with a human review between each.

Layout:

- `CONTEXT.md` (next to this file): the workspace routing. It lists the
  stages in order and the shared resources they draw on.
- `_config/`: shared reference material that stays the same across runs
  (the audience profile, the writing voice). This is the factory.
- `pipeline/`: the numbered stages. Each stage folder holds a `CONTEXT.md`
  contract, an `output/` folder for what that stage produces, and
  sometimes a `references/` folder for stage-local reference material.
- `pipeline/proposed/`: a staging area, not a stage. A human moves
  improvements from here into `_config/` once they are happy with them.

You only ever work on one stage at a time. The stage's own `CONTEXT.md`
tells you exactly what to read, what to do, and what to write. Do not read
files the stage contract does not list.

Next to it, CONTEXT.md is Layer 1, the routing that lists the stages:

# Routing

The course-research pipeline runs in four stages, in this order:

1. `01-scope`: turn the one-line course idea and the audience profile into
   a short list of scoped learning objectives.
2. `02-research`: for each objective, search the web and read sources, and
   write structured notes with links. This is the only stage that uses
   tools.
3. `03-outline`: turn the objectives and the notes into a module-by-module
   course outline. This is the gate before the expensive stage, so the
   outline is worth a careful human read.
4. `04-draft`: draft each module from the approved outline and the notes.

Each stage reads from the stage before it (its `output/` folder) and
writes to its own `output/` folder. A human reviews each output before the
next stage runs.

Shared resources (in `_config/`, the same every run):

- `audience.md`: who the course is for. Every stage should respect it.
- `voice.md`: how the course should read. The draft stage follows it.

Shared reference material sits in _config/, configured once and used on every run. Below is audience.md, a Layer 3 file that the scope, research and outline stages all read:

# Audience

The course is for working software developers who are comfortable with
Python and the command line, but who have not run a language model on
their own machine before.

They want practical, hands-on knowledge they can apply the same day, not
a survey of the field. Assume they can install software, edit a config
file, and read a stack trace. Do not assume they know any machine-learning
theory, model formats, or GPU terminology.

Keep examples concrete and runnable. Prefer one worked example over three
abstract options. When a trade-off matters, state it plainly and give a
default.

The 01-scope/CONTEXT.md contract is the core of the design. This is the complete one for the first pipeline stage:

# Stage: 01-scope

## Inputs
- Layer 4 (working): input.md
- Layer 3 (reference): ../../_config/audience.md

## Tools
none

## Process
Read the one-line course idea and the audience profile. Produce a short
list of scoped learning objectives for the course, four to six of them.

Each objective should be one sentence, start with a verb, and describe
something the learner will be able to do by the end. Keep the scope tight:
this is a short course, so leave out anything that does not fit a learner
going from zero to a working local model. Do not pad the list to hit a
number. Fewer, sharper objectives are better.

Write the objectives as a numbered markdown list, nothing else.

## Outputs
- objectives.md -> output/

The single Layer 4 input, 01-scope/input.md, is the working material for the run, the one-line idea that gets turned into a course:

# Course idea

A short, practical course that takes a developer from zero to running a
local large language model on their own machine with Ollama, and calling
it from a small Python script.

Some notes on how the contract is read:

  • The Inputs table lists the exact files a stage reads and tags each with a layer: Layer 3 for reference material that is stable across runs (the audience profile), Layer 4 for this run's working material (the one-line idea). Nothing else in the workspace gets loaded.
  • The Tools line says none, because turning an idea into objectives doesn't need tools. Stage 3 returns to this.
  • The Process section holds the instruction the agent follows, and it becomes the user turn.
  • The Outputs line names the file the stage writes into its own output/ folder.

From the contract, the loader turns the Inputs table into real file contents and builds the context out of them. Reference and working material stay apart since they play different roles: the first is a set of rules the model should follow, the second is raw input it should transform.

# System text: identity + routing + the reference material (Layer 3).
parts = []
if layer0:
    parts.append(layer0.strip())
if layer1:
    parts.append(layer1.strip())
refs3 = [r for r in inputs if r.layer == 3 and r.exists]
if refs3:
    block = ["# Reference material (follow these as constraints)"]
    for r in refs3:
        block.append(f"\n## {r.rel_path}\n\n{r.text.strip()}")
    parts.append("\n".join(block))
system_text = "\n\n".join(parts).strip()

The loader's behavior breaks down like this:

  • First come identity and routing (CLAUDE.md, CONTEXT.md), which tell the agent where it is and what stages exist.
  • Layer 3 reference files follow under a heading that presents them as constraints. Layer 4 working files are assembled apart, below the stage's Process text, as what the agent should act on.
  • The outcome is the system prompt for that one stage, built from plain files with nothing the stage didn't ask for.

Before running anything against the model, it pays to check what the loader produces, since that part is deterministic. The repository ships a small probe, probe_loader.py, which prints the assembled context for a stage with a rough token count and never calls the model. Run it from the stage2 folder against the first stage:

cd .\stage2\
python ..\probe_loader.py 01-scope

The screenshot shows the opening lines of the probe's report for the scope stage, with the context window size. That stage ends up with about 900 tokens, every one relevant, which makes the ICM idea tangible: a small scoped window per stage, and no single giant prompt holding everything.

To run the stage for real, use mcp-workflow-stage2. It parses the contract, runs the agent once and writes output/objectives.md. For the example idea, a practical short course on running a local LLM with Ollama, five tidy objectives came back, the sort a person scans and then keeps or cuts.

The banner in the screenshot shows the scoped context the probe predicted, near 900 tokens, and the stage's result is objectives.md. One run's objectives appear below; a second run would word them a little differently:

1. Install the necessary software stack (Ollama) and verify it is successfully pulling and serving a local LLM like `llama3`.
2. Configure environment variables to securely expose the local model's API endpoint for external access.
3. Write a Python script using the `requests` library to send prompts to Ollama and parse JSON responses into readable text.
4. Implement error handling in your script to gracefully manage connection timeouts or out-of-memory errors common with local inference.
5. Build an interactive loop that allows you to chat with the model directly from a terminal-based Python application without using a GUI framework.

With context now coming from files, the missing piece is running the stages in order and stopping between them.

Stage 3: the driver, the gates and tool scoping

A workflow means ordered stages with a human checkpoint between them. The driver therefore scans the numbered folders, runs the first stage with an empty output and halts.

async def run_pipeline(workspace_dir: Path, run_all: bool = False) -> None:
    stages = stage_dirs(workspace_dir)
    for sd in stages:
        if stage_is_done(sd):
            print(f"[skip] {sd.name}: output already present")
            continue
        await run_stage(workspace_dir, sd.name)
        if run_all:
            continue
        # The review gate. Stop here so the human can read and edit the
        # output before the next stage reads it.
        print("\n--- review gate ---")
        print(f"Review and edit if needed:\n  {sd / 'output'}")
        return

How it works:

  • State lives on disk. A stage counts as done when its output/ folder holds a real file, so there's no separate state store. The filesystem is the state.
  • The gate is just an early return. Once a stage finishes, the driver reports where to look and halts. You read the output, change it if needed and run the identical command again to proceed.
  • --all is the escape hatch that runs straight through without stopping, for when you trust the result. The default is gated, since the gates are the point.

To run the pipeline, use mcp-workflow-stage3, read what it wrote, then run it again to move forward. The first run covers 01-scope and halts; the second covers research and halts, and so on.

Each invocation handles one stage, reports where to look and halts at the review gate; running the same command resumes it.

Tool scoping

Research is the one stage that uses tools, and that's where the first real trouble appeared. The agent links to the search and browser servers, and the earlier version merged every tool from every server into one flat list and offered the entire list on each turn. That's about fifteen tools, of which research needs roughly five. The remainder are tab and action tools for working a page, which research never does.

Offered all fifteen, the 9B model drifted into the interaction tools. It picked open_tab and read_tab over the readers, and read_tab returns just the top of a page. The Ollama docs run on Mintlify, so that top is identical everywhere: the same navigation text. The model kept reading that menu, opening one tab after another, until the call cap ended the run with a 143-character apology as output.

The remedy is to scope tools to each stage. A contract lists the tools it needs, and at runtime only those get exposed. Here is the complete research contract:

# Stage: 02-research

## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 3 (reference): ../../_config/audience.md
- Layer 3 (reference): references/tool-use.md

## Tools
search-server: search
browser-server: summarize, extract, fetch_snippet, fetch_urls

## Process
For each learning objective, find and read one or two good current sources,
then write short, factual notes the outline and draft stages can rely on.

Work objective by objective. For each one: run a web search, then read the
most promising result with `summarize` or `extract`. Use `summarize(url,
question=...)` for an open question about a page, and `extract(url, schema)`
when you can name the fields you want (a version number, a default port).
These read the whole page; do not try to page through a site by hand. Read
each page at most once and do not re-read a page you have already read. Once
you have one or two sources per objective, stop calling tools and write the
notes. Pull out the concrete facts that matter for teaching this objective
(commands, version notes, default ports, common pitfalls). Prefer official
documentation and project sources over blog aggregators.

Write the notes as markdown, grouped under a heading per objective. Under
each heading, a few bullet points of facts, then a "Sources:" line with
the URLs you actually read. Keep it factual. Do not draft course prose here;
that happens later.

## Outputs
- notes.md -> output/

The new Tools section names search on the search server and only four tools from the browser server, all readers. The runtime applies that as a filter on the tool list before the model gets it:

def rebuild_ollama_tools(self, allowed: dict[str, set[str] | None] | None = None):
    out = []
    self._tool_to_server = {}
    for server_name, tools in self.mcp_tools_by_server.items():
        allow_set = allowed.get(server_name) if allowed else None
        for tool in tools:
            if allow_set is not None and tool.name not in allow_set:
                continue
            prefixed = f"{server_name}_{tool.name}"
            self._tool_to_server[prefixed] = server_name
            out.append({
                "type": "function",
                "function": {
                    "name": prefixed,
                    "description": tool.description or "",
                    "parameters": tool.inputSchema,
                },
            })
    self.ollama_tools = out

The filter works like this:

  • The allow-map is per server. None for a server means all of its tools, and a set of names means only those.
  • Any tool outside the allow-set is dropped, so the model never sees it. Research now sees five tools, while scope, outline and draft see none.
  • The map is rebuilt in step, so a hidden tool can't be called either; a hallucinated open_tab call would simply return as an unknown tool.

The research contract further ships its own Layer 3 reference, references/tool-use.md, limited to this stage, explaining how to use the readers that remain:

# Tool use (research stage only)

You have two MCP servers for this stage. Use them like this.

- `search-server_search(query, max_results)`: a web search. Start here for
  any lookup. It returns URLs with titles and snippets. After a search,
  your next action must be a browser-server call on the most promising URL.
  Do not stop after a search and do not answer from the snippets alone.

The browser-server reader tools each return a small slice of a page, not
the raw page:

- `browser-server_fetch_snippet(url)`: the head of a page, for a quick look.
- `browser-server_fetch_urls(url)`: the page's links, to decide what to read next.
- `browser-server_fetch_structure(url)`: the page's heading outline.
- `browser-server_extract(url, schema)`: pull named fields you can list in
  advance (a version number, a default port) using a JSON Schema.
- `browser-server_summarize(url, question="")`: a prose summary of a whole
  page; pass `question` to focus it on what this objective needs.

Prefer `extract` when you know the field names, `summarize` for an open
question about a long page, and `fetch_snippet` for a quick confirmation.
Prefer official documentation and project sources over blog aggregators.

Once more the filesystem plays the framework's role. In a framework the agent's tool set is code; here it's one markdown line anyone can edit. With the readers in and the tab tools out, the model called summarize and extract, which read entire pages through the chunked reader, and the notes held real findings: the install command, the default port 11434 and how to use the Python library, each with a source link.

When the model can't stop

A second issue was subtler. Equipped with the right tools, the model read good pages yet never recognized when to finish. In one run the same page was summarized with the same question over a dozen times with no new progress until the call cap hit. The useful findings were sitting in the message history, but with no final answer written, the output was again the apology.

To handle that, the runner now does one extra step whenever a turn gets cut short: a last call with no tools, instructing the model to write its answer from what it has collected.

async def _finalize(self, stop_reason: str) -> dict:
    if not self.synthesize_on_stop:
        self.messages.append({"role": "assistant", "content": stop_reason})
        return {"role": "assistant", "content": stop_reason}
    self.messages.append({
        "role": "user",
        "content": ("Stop using tools now. Using only the information you have "
                    "already gathered above, write the final output the task "
                    "asked for. Do not call any tools."),
    })
    resp = ollama.chat(model=MODEL_NAME, messages=self.build_messages_for_model(),
                       options={"temperature": MODEL_TEMPERATURE}, think=MODEL_THINKING)
    msg = resp["message"]
    content = msg.get("content", "") if isinstance(msg, dict) else getattr(msg, "content", "")
    final = {"role": "assistant", "content": content or stop_reason}
    self.messages.append(final)
    return final

Three details matter:

  • It's opt-in. The interactive agent leaves synthesize_on_stop off and simply reports the stop, as before, while the workflow turns it on so that a stage always writes real output.
  • It rescues the work. Tool results already sit in the history, so the tool-free call can write the notes from them, turning a runaway turn into a usable notes.md instead of an apology.
  • A stuck-loop guard helps. The same call repeated three times in a row now ends the turn early, errored or not, so the model spins less before synthesis takes over.

A clean run of research lasted around seven minutes and yielded just over 10,000 characters of sourced notes for the five objectives.

The screenshot shows the second tool run finalizing and asking for a review of the output. With the gates, scoping and salvage in place, research is reliable enough to build on, and the next gate matters most.

Stage 4: the outline gate

Of all the stages, outlining deserves the most care, because drafting comes next and drafting is the costly step. A mistake caught at the outline costs the least to fix. The stage combines two previous outputs, the objectives from 01-scope and the research notes from 02-research, into an outline organized by module. The contract reads:

# Stage: 03-outline

## Inputs
- Layer 4 (working): ../01-scope/output/objectives.md
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/audience.md

## Tools
none

## Process
Turn the objectives and the research notes into a module-by-module course
outline. One module per objective is a reasonable default, but merge or
split where the notes suggest it.

For each module, give a short title and three to five bullet points naming
what it covers, drawn from the notes. Order the modules so each one builds
on the one before it. Note any module that depends on something from an
earlier module.

This outline is the last checkpoint before the course is drafted, so make
it easy for a human to read and correct. Write it as markdown headings with
bullet points. Do not draft the module text yet.

## Outputs
- outline.md -> output/

Those two Layer 4 lines are what's new. They make the multi-pass handoff from the ICM paper concrete, since a stage's output feeds the following stage and this one takes two earlier results together. The loader pulls in both as working material and the model produces the outline.

Launch it with mcp-workflow-stage4. Since scope and research are finished, the run executes 03-outline, writes outline.md and pauses at the gate.

Running mcp-workflow-stage4 produces the outline and halts at the gate, the last cheap place to fix the course before drafting.

The gate proved its worth at once. During one run, research collected too much. The course targets someone going from nothing to a running model and a short script, yet the notes strayed into cloud API keys and KV-cache quantization, which the model had come across in the docs and faithfully recorded. The outline inherited that and grew an entire module called "Securing Your Connection with Environment Variables", full of content beginners don't need.

Nothing here went wrong with the workflow. The excess showed up as a readable heading in a plain file, before any course text was written. Delete the module, reshape its lessons or point it elsewhere, and drafting never spends a call on it. The repair is a single edit to a markdown file, whereas after drafting it would take rereading five modules and numerous lessons to find the one that shouldn't be there.

An example outline is open for editing in VS Code at the gate, where off-scope modules or lessons get cut before the draft stage ever sees them.

At the gate, cut the outline down to the modules and lessons you want, then rerun. The module count is simply how many ## Module headings the file holds, so trimming the outline also decides the course length.

Stage 5: drafting per module

Drafting writes every module from the approved outline, taking facts from the notes. The first attempt asked for the entire course in one turn and fell short: some runs produced four modules, others produced one and declared victory. A small model sees "write the whole course" as one huge task and tends to stop early.

So the draft stage loops over the modules instead. Its full contract includes a For each directive that declares the loop:

# Stage: 04-draft

## Inputs
- Layer 4 (working): ../02-research/output/notes.md
- Layer 3 (reference): ../../_config/voice.md
- Layer 3 (reference): ../../_config/audience.md

## Tools
none

## For each
module in ../03-outline/output/outline.md

## Process
Draft the one module shown below, and only that module. Use the research
notes for the facts (commands, ports, pitfalls) rather than inventing them,
and follow the voice and audience guides. Write a few short paragraphs of
teaching prose plus any commands the learner should run. Do not write the
other modules. Start your output with the module's heading, and output only
the drafted module, nothing else.

This is a first draft for a human to revise, so aim for a clear, correct
scaffold rather than a finished, polished module.

## Outputs
- draft.md -> output/

Drafting is the lone stage that reads voice.md, the final Layer 3 file in the workspace, which defines the course's tone:

# Voice

Write plainly, as one developer explaining something to another at the
next desk. Short instructional sentences, followed by a longer one that
explains why.

- Lead with what to do, then explain the reasoning.
- Use concrete names: real tools, real commands, real file paths.
- Hedge honestly when something is a judgement call or can fail.
- No marketing language. No hype. No "unlock", "seamless", "robust".
- One idea per paragraph. Bullets only for listing the parts of a thing.

The For each line is what converts one open-ended turn into a loop, and the runtime runs the Process once per module:

fe = ctx.for_each
print(f"  for each: {len(fe.modules)} {fe.item}(s) from {fe.rel_path}")
pieces = []
for i, (heading, body) in enumerate(fe.modules, 1):
    print(f"  [{i}/{len(fe.modules)}] {heading}")
    agent.messages = []
    item_block = (f"# The {fe.item} to write now\n\n"
                  f"## {heading}\n\n{body}")
    agent.messages.append({"role": "user", "content": ctx.user_text + "\n\n" + item_block})
    final = await agent.run_turn()
    pieces.append((final.get("content", "") or "").strip())
content = "\n\n".join(p for p in pieces if p)

What the loop does:

  • The outline is split into modules at the ## headings and the loop runs once for each. The count isn't fixed at five; four modules mean four passes and six mean six, matching whatever survived the gate.
  • Every call begins clean. agent.messages is emptied each time, so a module is drafted with the notes at hand and no recollection of its siblings, keeping each turn small and self-contained.
  • Results are concatenated into one draft.md. One bounded call per module, in place of a single "write the course" request, is what lets the 9B model complete them all.

The directive is general and not special-cased for drafting. Any stage could declare For each, though the other three have no directive and run as a single turn. Running mcp-workflow-stage5 shows [1/5] through [5/5] going by, and the sample run wrote just over 15,000 characters across five modules.

The draft stage loops over the outline with one model call per module. The count is whatever the outline still holds, so four or six modules mean four or six runs.

The workspace also contains a proposed/ folder that the pipeline never executes. It's a holding area: when a run suggests an improvement that should outlast it, say a sharper objective or a voice rule drafting keeps breaking, the suggestion is saved there as a file, and a person moves it into _config/ if they agree. The human gate matters here. Nothing alters the workspace until somebody promotes a change manually, which preserves review as ICM intends, and it gives later automation a place to hook in.

What works and what doesn't

A 9B model drafting the modules produces serviceable scaffolding and not finished teaching. The win is the workflow and its interpretability, and a local model writing a course unattended isn't the claim.

The rough edges are real ones seen in runs:

  • The draft boldly produced a broken systemd unit in which the [Unit] and [Service] lines collapsed into one and environment variables were stuffed into ExecStart.
  • A version string appeared that appears nowhere in the notes, and model names wobbled across modules between gemma2, gemma3 and gemma3:2b.
  • With every module written independently and blind to the rest, Modules 1 and 2 overlapped substantially, each covering environment variables and systemd.
  • Research drags at roughly seven minutes, since each page goes through the chunked summarizer; the Ollama FAQ alone ran 41,000 characters over eight chunks.
  • One source was a Cloudflare bot-check wall. The reader correctly reported that it held no real content, so the model moved on without inventing some.

None of this makes the agent a deterministic pipeline, and anyone who needs one should write a script. The benefit is that the steering sits in files a person reads and corrects between stages, so a rough draft gets fixed at a gate and isn't found later by surprise. The scope contract's "four to six objectives" line is only a hint to the model until the file is edited, and a firm fact afterward. The distance between what the model tends to do and what a person pins down is exactly why gates exist.

Everything together

In the end there's one workspace folder per pipeline, next to the agent, with a CONTEXT.md contract for each stage and an output/ folder carrying results forward. The agent is the same multi-server agent as before, and the runtime that drives it, meaning the loader, gated driver, tool scoping and per-module loop, fits in a single file of about 400 lines.

The pieces, in summary:

  • A workspace where per-stage context lives in numbered folders of plain markdown, editable without touching code.
  • A loader that builds each stage's system prompt from files, keeping reference and working material apart, in a focused window of roughly 900 to 1,500 tokens per stage.
  • A driver with gates that executes one stage, pauses for a person and picks up again, keeping its state on disk.
  • Tool scoping by stage, limiting each stage to what its contract lists.
  • A fallback that writes the answer from gathered results when the model keeps researching, so output is never empty.
  • Per-module drafting, where the module count is the number of headings in the approved outline.

A workspace being just a folder means a different job needs only a different folder. The repository includes a second one, workspace-rust, for a beginners' Rust course. It copies the first and differs in two files only, the audience profile and the one-line idea, while every stage contract, the routing file and the tool-use reference remain byte-identical. Running mcp-workflow-stage5 --workspace workspace-rust gets a Rust course from the same pipeline. That's ICM's notion of configuring the factory rather than the product.

The two workspaces differ only in audience.md and the one-line idea: same pipeline, different course.

There's room to extend this. A Verify section in a stage contract could make a stage reread an earlier output and flag drift before a human looks, which the ICM paper sketches as cross-stage verification. A one-line summary of prior modules could feed each draft call, trimming the overlap between Modules 1 and 2. And the proposed/ folder could become something that fills itself.

Why hand-build this thin version when CrewAI and LangGraph exist? They're good tools, and a workflow that needs concurrent agents, dynamic branching or real deployment justifies their weight. Many jobs, however, are sequential, reviewable and repeatable, and carry no need for that. For those, the simplest answer already exists on every machine: a folder of files. Building it first also shows where heavier tools start to pay off.