# Using Goose as the Harness in a Software Factory

Date: 2026-07-31

Goose makes a good headless harness for a software factory precisely because it doesn't try to be the whole factory. Here's what it gave me, and what I built around it.

---

I'm building a "software factory": a team of agents that identify, scope, build, review and release work across several projects, sometimes alone and sometimes together. It is not as easy as that sentence makes it sound, and the interesting part has been everything that stringing agents together breaks.

The first choice was light or dark. Manufacturing has a name for the fully automated plant that runs with the lights off and nobody on the floor: the dark factory. The light factory keeps people at the stations. Most of the software automation being built right now sits somewhere between the two.

Mine is _mostly_ dark with one light left on. A job queue feeds work to agents, the agents produce diffs, and a human approves through Slack before anything merges.

I'd recommend starting there. Putting yourself in the loop surfaces far more problems with the prompts, the models and the sequencing, and you can always turn the lights off later. Going the other way is a recipe for pain and sadness.

Every agent in that pipeline needs something to actually run it. Something that takes a job off the queue, starts an agent with the right instructions and the right access to the code, lets it work, and reports back when it's done. That's the harness. It's the least interesting part of the system to talk about and the part everything else sits on top of.

I'm using [Goose](https://github.com/aaif-goose/goose) for that job. This post is about why it fits this shape of problem, what it handed me for free, and the specific things I had to build around it once it was doing real work.

## What is Goose?

Goose is an open source agent that's now flapping its wings under the [Agentic AI Foundation](https://aaif.io/) banner. It runs headless in a container, talks to an LLM, and does work through the shell and through [MCP](https://modelcontextprotocol.io) extensions.

Point it at a task and it plans, edits files, runs commands, and stops when it decides it is done or hits a limit.

Configuration-wise, Goose uses [recipes](https://goose-docs.ai/docs/guides/recipes/recipe-reference/): declarative YAML files holding settings, extensions, parameters, instructions, and a prompt. One recipe describes one kind of job.

The list of what Goose is _not_ is just as short, and it's the reason I picked it. It is not an orchestrator, not a queue, not a scheduler, not a policy engine.

Every one of those absences is deliberate, which keeps the tool small and predictable and leaves the interesting decisions to me. Most of this post is about where those edges are.

## Recipes are the reason

Goose recipes are worth reading even if you never run the tool. A recipe is a file. It lives in the repo, so it goes through review and diffs like anything else. The behaviour of an agent becomes something you read in a pull request instead of a setting buried in a dashboard.

Here is a trimmed version of my `scope` recipe. The interesting line is actually the comment.

```yaml
# recipes/scope.yaml
version: "1.0.0"
title: "Scope an issue"
description: "Read the issue and the repo, produce an implementation plan"

settings:
  goose_provider: anthropic
  goose_model: claude-opus-5
  # No temperature. Some providers reject the field with a 400,
  # and a value you pin but cannot send fails the run rather than
  # being quietly dropped. Absence is the portable choice.

extensions:
  - type: builtin
    name: developer   # shell + file write; see "Tools are a hint" below

parameters:
  - key: context_path
    input_type: string
    requirement: required
    description: "Path to the rendered issue context file"

instructions: |
  You are scoping, not building. Do not modify tracked files.
prompt: |
  Read {{ context_path }} and the repository, then write a plan.
```

What? A comment? Why is that interesting?

Well, the comment carries as much of the artifact as the keys do. A future reader doesn't have to rediscover why `temperature` is missing, or indeed just try to add it back, manually or with their agent, because the reasoning sits next to the decision, in the file, under version control.

That's something I want from configuration, and something a pure markdown implementation wouldn't have given me.

The rest of the argument follows from the same fact:

- **Changing a model is a pull request.** Swapping `claude-opus-5` for something else is a one-line diff with an author and a date, so a shift in output quality correlates with a commit you can point at instead of a hunch.
- **Headless is the default.** `goose run` needs no terminal UI, which is exactly what you want when the caller is a job queue and not a person.
- **It is self-hostable.** Nothing phones home to a control plane you don't own, which matters when the factory runs on your own hardware (in my case this whole factory will end up on a VPS from Hetzner).

## What does a "run" look like?

One issue moves through the system as a single unit of work. I'm using [Linear](https://linear.app) as the task queue and the state machine combined: moving an issue from one column to the next is what triggers the next step.

A dispatcher script picks up the job, a Dockerised runner boots, and Goose runs inside it.

Three jobs means three recipes, each mapped to a Linear state:

| Recipe | What it does | Access |
| --- | --- | --- |
| `scope` | Reads the issue and the repo, writes an implementation plan | Read-only |
| `build` | Implements the plan, produces a diff | Read and write |
| `review` | Checks the diff against the plan and the declared boundaries | Read-only |

They run on different models. End to end, a single issue travels like this, and the labels under the row mark where each control is actually enforced.

<style>
.pipeline-flow{margin:2rem 0}
.pipeline-flow svg{width:100%;height:auto;display:block;overflow:visible;
  --flow-text:var(--color-text);--flow-muted:var(--color-muted);--flow-accent:var(--color-accent);
  --flow-border:var(--color-border);--flow-surface:var(--color-surface)}
.dark .pipeline-flow svg{
  --flow-text:var(--color-text-dark);--flow-muted:var(--color-muted-dark);--flow-accent:var(--color-accent-dark);
  --flow-border:var(--color-border-dark);--flow-surface:var(--color-surface-dark)}
.pipeline-flow text{font-family:ui-monospace,'JetBrains Mono',monospace}
.pipeline-flow .fk-node{fill:var(--flow-surface);stroke:var(--flow-border)}
.pipeline-flow .fk-label{fill:var(--flow-text);font-size:11px}
.pipeline-flow .fk-link{stroke:var(--flow-muted)}
.pipeline-flow .fk-mark{stroke:var(--flow-accent);fill:none}
.pipeline-flow .fk-mark-label{fill:var(--flow-accent);font-size:9px}
.pipeline-flow figcaption{margin-top:.75rem;font-family:ui-monospace,'JetBrains Mono',monospace;font-size:.75rem;color:var(--color-muted)}
.dark .pipeline-flow figcaption{color:var(--color-muted-dark)}
</style>
<figure class="pipeline-flow">
<svg viewBox="0 0 908 140" role="img" aria-labelledby="fk-t fk-d">
  <title id="fk-t">The project run pipeline</title>
  <desc id="fk-d">A Linear issue becomes a pg-boss job, boots a container, runs a Goose recipe, produces a diff and exit code, passes a Slack human gate, then merges. A read-only mount enforces the scope and review recipes; a runner path check enforces the build recipe; the Slack gate is the human approval.</desc>
  <defs>
    <marker id="fk-arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
      <path d="M0 1 L9 5 L0 9" fill="none" stroke="var(--flow-muted)" stroke-width="1.4"/>
    </marker>
  </defs>
  <g text-anchor="middle">
    <rect class="fk-node" x="20"  y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="76"  y="49">Linear</text><text class="fk-label" x="76"  y="63">issue</text>
    <rect class="fk-node" x="146" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="202" y="49">pg-boss</text><text class="fk-label" x="202" y="63">job</text>
    <rect class="fk-node" x="272" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="328" y="49">container</text><text class="fk-label" x="328" y="63">boot</text>
    <rect class="fk-node" x="398" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="454" y="49">recipe</text><text class="fk-label" x="454" y="63">run</text>
    <rect class="fk-node" x="524" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="580" y="49">diff +</text><text class="fk-label" x="580" y="63">exit code</text>
    <rect class="fk-node" x="650" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="706" y="49">Slack</text><text class="fk-label" x="706" y="63">gate</text>
    <rect class="fk-node" x="776" y="28" width="112" height="48" rx="6"/>
    <text class="fk-label" x="832" y="56">merge</text>
  </g>
  <g class="fk-link" stroke-width="1.4" marker-end="url(#fk-arrow)">
    <line x1="132" y1="52" x2="145" y2="52"/>
    <line x1="258" y1="52" x2="271" y2="52"/>
    <line x1="384" y1="52" x2="397" y2="52"/>
    <line x1="510" y1="52" x2="523" y2="52"/>
    <line x1="636" y1="52" x2="649" y2="52"/>
    <line x1="762" y1="52" x2="775" y2="52"/>
  </g>
  <g text-anchor="middle">
    <path class="fk-mark" d="M272 92 V96 H510 V92" stroke-width="1"/>
    <text class="fk-mark-label" x="391" y="112">read-only mount</text>
    <text class="fk-mark-label" x="391" y="124">scope · review</text>
    <path class="fk-mark" d="M524 92 V96 H636 V92" stroke-width="1"/>
    <text class="fk-mark-label" x="580" y="112">runner check</text>
    <text class="fk-mark-label" x="580" y="124">build</text>
    <path class="fk-mark" d="M650 92 V96 H762 V92" stroke-width="1"/>
    <text class="fk-mark-label" x="706" y="112">human gate</text>
  </g>
</svg>
<figcaption>Goose only does the middle step. Every rule is enforced outside it: the read-only mount while the recipe runs, the path check on the diff, and the human on the Slack gate.</figcaption>
</figure>

## What the harness gives you

Four things about Goose make that pipeline straightforward to build, and none of them took any work on my side:

- **A run is one container.** Goose boots, does one job, and exits. Nothing carries over, so there's no state to reset and a crashed run leaves nothing behind. Retrying is just running it again.
- **The contract is an exit code and a diff.** That's a small enough surface to orchestrate from a shell script, which is exactly what you want when the caller is a job queue. Anything richer would be something else to keep in sync.
- **Extensions are just MCP.** Whatever the agent needs to reach, it reaches through a protocol the rest of the ecosystem already speaks, not a plugin API belonging to the harness.
- **The model is one line per recipe.** `scope` can run on a big model and `review` on a cheap one, and changing that is a diff rather than a deploy.

Between them, that means I don't think about the harness much. It runs the recipe. The rest of this post is the layer around it, which is where the work went.

## Where the guarantees actually live

A recipe describes a job. It doesn't police one, and Goose never claims otherwise. But a YAML file full of sensible-looking keys reads like a set of guarantees, and that's the assumption to drop early. Each of the things below is somewhere I made that mistake, and then moved the control to a layer that could actually hold it.

Same treatment for each: what broke, what I did about it, what it taught me.

### Pass a filename, not a payload

Goose pastes `--params` values into the recipe _before_ it reads the YAML. It's a find-and-replace, not a real parameter, so a value with line breaks in it puts those line breaks into the middle of the file and it stops parsing. Indenting doesn't help:

```bash
# Breaks: the newlines land in the middle of the YAML document
goose run --recipe scope.yaml --params context="Issue FAK-12
Steps to reproduce: ..."

# Works: pass a filename and let the agent read it
goose run --recipe scope.yaml --params context_path=/work/context.md
```

So don't pass the text; pass a filename. The runner writes the context to a file in the container, and reading that file is the agent's first job.

**Learning:** When the harness pastes text rather than passing structure, the filesystem is your parameter channel.

### Put boundaries where the runner reads them

The rules about which files the `build` recipe may write started out in the recipe itself, like this:

```yaml
# Goose never reads this key. It is a comment with extra steps.
write_allowances:
  - src/**
  - tests/**
```

It looked like it worked. It didn't. Goose skips any top-level key it doesn't recognise, without complaining, so the recipe was describing a rule nothing ever read.

The rules moved to the project's own config, where the runner enforces them for real. And if that key turns up in a recipe again, the runner refuses to start, so the mistake is loud instead of silent.

**Learning:** A control that lives in a file the harness ignores is not a control. This is the headline lesson, and the rest of this section keeps repeating it.

### Tools are a hint, the mount is the control

`available_tools` looks like an allow-list. It isn't one. The builtin `developer` extension has no tool for reading a file, so the agent reads by running `cat` in the shell:

```yaml
available_tools:
  - developer__shell # the only way to read a file
```

That leaves two options and nothing in between. No shell, and the agent can't read the repo at all. A shell, and it can run anything.

The `scope` recipe picks the shell, and the container takes the risk back out. The repo is mounted read-only, so every write fails no matter how the agent tries it:

```bash
$ echo "x" >> src/app.ts
sh: src/app.ts: Read-only file system
```

The recipe isn't what stops the write. The mount is. After each run the runner checks the worktree is still clean anyway, in case that assumption is ever wrong.

**Learning:** `available_tools` documents intent, the mount enforces it.

### When the `build` recipe has to write

The read-only mount works for `scope` and `review` because both only read the repo. It's no use for `build`, the recipe that actually implements the plan and produces the diff, because an agent that can't write can't build anything.

So `build` gets a writable repo and the check moves to after the run: once the agent has finished, the runner looks at every file that changed and asks whether that file was one it was allowed to touch.

```
src/api/users.ts          ok
tests/users.test.ts       ok
.github/workflows/ci.yml  not allowed -> run fails
```

One file in the wrong place fails the whole run, good work in the same diff included. There's no partial pass. Symlinks are followed first, so a link pointing somewhere it shouldn't counts as a breach rather than a way around the rule.

**Learning:** The control that matters is the one between the diff and the push.

### Leave out settings you can't send everywhere

None of the three recipes set `temperature`, and that's deliberate. Some models don't accept the field at all:

```
400 invalid_request_error: `temperature` is not supported by this model
```

This is the ignored-key problem in reverse. There, a setting nothing read was silently dropped. Here, a setting the model won't take kills the run. Either way you don't get the control you thought you had, so I leave it out and let each model use its own default.

**Learning:** A setting you cannot send everywhere is not a standard you can hold.

### Measure cost outside the harness

Goose won't tell you what a run cost. You get an exit code and a diff, and that's it:

```bash
$ goose run --recipe build.yaml --params context_path=/work/context.md
$ echo $?
0   # that is the whole report
```

Which means there's no way to see a loop spending money without getting anywhere, and no number to stop it on. Fair enough for a tool that runs one job and exits, but a factory runs thousands of them.

So I work the cost out afterwards, from the provider's own usage data and the model named in the recipe. It works, and it's what makes running three recipes on three different models worth the effort, because you can only justify that split if you can see what each one costs.

It's also the obvious thing to replace. [agentgateway](https://agentgateway.dev), another Agentic AI Foundation project, sits in front of LLM and MCP traffic and reports on it: token-usage metrics, and the realised cost of every call attached to the logs and traces. Put the factory's traffic through it and cost stops being something I reconstruct after the fact.

I plan on adding it now the baseline is down. Working the numbers out by hand first was worth doing anyway, because it means I'll know whether the gateway is telling me the truth.

**Learning:** If the harness won't measure it, the factory has to, before a loop can be trusted to run unattended.

### Make workarounds cheap to delete

There's no Linear extension for Goose, so the issue gets pasted into the prompt instead. That's a workaround, and one day it can go, so I made it easy to pull out: the issue always sits between two marker comments, and each recipe takes exactly one context parameter, never more.

```markdown
<!-- BEGIN project context -->

...rendered Linear issue...

<!-- END project context -->
```

When the extension arrives, removing this is three edits: delete the block, delete the one line in the prompt that points at it, add the extension. No hunting through recipes for all the places the issue text leaked into.

**Learning:** Temporary things last longer than you plan for, so make them cheap to remove.

## What this bought me

The payoff is boring in the way good infrastructure is boring.

- A change in agent behaviour is a YAML diff in review, with an author and a reason.
- A new kind of loop is a new recipe and a new Linear state, not a new system.
- A bad run costs one container and one diff, and nothing downstream of the Slack gate ever sees it.
- There is a clear line between what the harness enforces, which is deliberately little, and what the runner enforces, which is everything that would hurt to get wrong.

That last one took the longest to work out. If you're building something similar, this is what I'd tell you:

- **Let the harness be small.** Anything it doesn't do is a decision you get to make yourself, and most of mine went differently than a batteries-included tool would have made them.
- **Put every real control below the agent.** A read-only mount, a path check between the diff and the push, a human on the gate. Config describes intent; the layer underneath enforces it.
- **Keep the behaviour in the repo.** Recipes in git turn "why is the agent doing that?" into a question you answer with `git log` rather than a hunch.
- **Measure what the harness won't.** Cost, in particular, before you let anything run unattended.

## Why Goose is the right harness for this

Every item on that list is something I had to build, and I'd still pick Goose again tomorrow.

That's down to what it refuses to do. Goose runs one recipe in one container and hands back an exit code, reliably, every time, and then it gets out of the way. It has no opinion about my queue, my state machine, my approval flow or my boundaries, which is why all four of those could be mine.

A harness that shipped its own versions would have been quicker to start with and a problem six months later, because a system like this outgrows someone else's idea of how it should work fairly quickly.

What I get instead is agent behaviour that lives in a file, in the repo, in code review, running in a container that boots, works, and disappears. For a factory, that's the important part. Everything else in this post is scaffolding, and I got to build it the way my system needed.

The recipe describes the job and the runner enforces it. Once that's clear, the small surface is a feature rather than a gap.

---
Source: https://martyndavies.me/blog/goose-as-the-harness.md