Autonomous agents

How I created infrastructure to run agents across repositories while limiting their control

Overview

Coding agents have become part of how many of us build software. As a student, I’m still sceptical about leaning on them too heavily, so I deliberately limit how much I use them. Last year I started with tools like NotebookLM to support my learning. This year, during the internship and my work (I have been working full time as a software engineer through my degree), I began experimenting on real code.

The same question kept coming back: in the grand scheme of things, which changes are worth handing to an agent entirely, if any? Can you fully rely on one in any situation?

John Ousterhout’s distinction between tactical and strategic programming tried to helping me answer this question. Tactical programming prioritises getting a feature or fix working quickly; strategic programming invests in a design that stays easy to change over time.

These paradigms of “shipping” are not interchangeable; they complement each other. In my opinion, every piece of strategic programming has its own tactical parts. Through that lens, I started thinking about how I could use agents for the tactical work once the strategic part is settled.

Problems that can be trivially solved

I start every big project with a design document: answering the architectural questions first, then decomposing the problem into subparts that add up to the larger solution. Those subparts can get small enough that once the ambiguities are resolved, the change is trivial and the only thing left to pay is the time to type it out. In the era of coding agents, that class of problem sounds like a good one to hand over.

Reading about Stripe’s Minions and trying Google Jules made me wonder whether I could automate that class of task, leaving only my judgement on the finished code.

That’s where the idea for Patchdock came from. What if each repository could predefine its own pipeline of agents, without being locked to a vendor, running any LLM provider within clear limits? Different repositories have different needs, and a small project doesn’t need the most capable model for every task.

The idea was to give those agents a fixed loop: plan, execute, review. If the reviewer requested changes, the executor would try again within a retry limit. Once accepted, the result would land on a separate branch for me to review.

Usage

Start with a running Docker Engine, then install the dock CLI through Homebrew:

brew install HJyup/tap/patchdock

Setting up a repository, writing agent definitions, and the rest of the commands are covered in the Patchdock repository.

Each repository owns its own execution limits. Here is an example of .patchdock/config.yml:

stages:
  planner: planner.ts
  executor: executor.ts
  reviewer: reviewer.ts

container:
  timeout: 10m
  token_budget: 100000

retries:
  max: 3

The timeout is a hard wall-clock limit for each stage. The token budget is advisory, passed to the agent in its context. The retry limit caps the executor and reviewer rounds. Configuration is loaded fresh for each run, so changes do not require a daemon restart.

Running dock opens the task input. For a quick submission from the terminal, pass the task directly; the command prints a run ID and exits while the daemon continues the work.

dock "Fix failing test in reviewer schema sdk"

All running pipelines can be seen in the TUI with the dock watch command.

The dock main window listing runs and their current progress
dock main window with current progress

Architecture

The most interesting question came up when I added an MCP connector to Claude Code, so the main agent could spawn my pipelines. If several agents can start runs, where should the central state of those pipelines live? My idea of a run is different from Claude Code’s: I don’t want to keep a terminal open, and often I don’t need to watch what a pipeline does, only whether it finished. The answer came from learning Docker for this project. Anyone can start a container, but Docker runs a daemon process that manages them. What if I did the same?

Terminal clients
dock · dock watch · multiple repositories
MCP
Terminal agents such as Codex or Claude Code
HTTP over a Unix socket · SSE snapshots back to clients
Local daemonOne process · file lock
Router → Service
Submit a task or follow the live state feed.
Queue
Owns run state.
Starts and cancels runs.
Broker
Latest snapshot.
Fans out to clients.
Queue starts a pipeline for each run
Run pipeline
Loads config, runs the stages, and publishes accepted changes.
The pipeline manages
.patchdock/
Config, Dockerfile, and agent definitions.
Docker Engine
A fresh container for each stage.
Git workspace
Temporary clone and published branch.

A single goroutine owns the run table. Submissions, cancellations, and pipeline updates arrive through a buffered channel as typed events. Serialising mutations in one place keeps the queue’s state management free of shared-state locking.

The dashboard only shows changes as they happen; it keeps no history of a project. That is deliberate: for the way I use Patchdock, the dashboard should show the active state and nothing else. The log of each run lives in the .patchdock/ folder of the repository it belongs to.

Since I am not dealing with history and already have a broker, I settled on a single-snapshot channel. If nothing reads the process, the queue can produce a lot of snapshots, and by Go’s design, sending something to a channel requires someone to receive it. So when a new snapshot arrives, the queue drains the channel before placing it, which means the channel never holds more than one.

Run pipeline

Before planning begins, the daemon loads the repository configuration, opens an audit directory, resolves the configured credentials, and ensures the agent image exists. A missing image is built from the repository’s Dockerfile; later runs reuse its tag.

  1. Planner
    Read-only /repo → validated plan
    Clone the repository and lock the base commit
  2. Executor
    Read-write /workspace → file changes and execution result
    The host stages changes and extracts the diff
  3. Reviewer
    Read-only /workspace → accept or reject with feedback
Accept → publish
Create a branch and commit in the clone, then push back to the local repository.
Reject ↩ executor
Pass feedback to the next attempt. Stop as rejected when the retry limit is reached.

Each stage is a TypeScript file with a default-exported SDK definition. Patchdock supplies ctx and the typed input. The context includes the run ID, assigned paths, token budget, attempt counters, and a logging function. The examples below use the built-in Codex adapter.

Planner. In .patchdock/planner.ts, the agent receives the task and returns a plan with a non-empty summary and Markdown body. Its repository mount is read-only.

import { codex, definePlanner } from "@patchdock/sdk";

export default definePlanner({
  async run(ctx, input) {
    return codex(ctx, input);
  },
});

Executor. Once the plan is valid, Patchdock makes a local Git clone and locks its base commit. The executor receives the plan and previous review feedback. It edits files beneath ctx.paths.workspace and returns a status with optional notes. It does not return a patch: the host stages the workspace and calculates the diff against the locked base.

import { codex, defineExecutor } from "@patchdock/sdk";

export default defineExecutor({
  async run(ctx, input) {
    ctx.log(
      `Executor attempt ${ctx.attempt}/${ctx.maxAttempts}`,
    );

    if (input.reviews.length > 0) {
      ctx.log("Applying previous review feedback");
    }

    return codex(ctx, input);
  },
});

Reviewer. The last agent inspects the plan, patch, execution history, and previous reviews from a read-only workspace. An accepted review can proceed to publishing. A rejection must include feedback, which becomes context for the next executor attempt.

import { codex, defineReviewer } from "@patchdock/sdk";

export default defineReviewer({
  async run(ctx, input) {
    return codex(ctx, input);
  },
});

These definitions can use another model or custom logic instead of Codex, provided they return the stage’s required contract. Returning invalid data stops the stage; it is never passed to the next agent.

Each stage runs in its own Docker container with its own mounts. The executor gets a copy of the repository with write access, while the planner sees the real repository read-only. A stage is defined by what it should do, and the mounts enforce that role. This matters when I test several models against the same stage, and more importantly because stages can be invoked on their own (over MCP you might call only the planner), so the container’s rules, rather than its place in the pipeline, are what limit it.

Every workspace and cloned repository is removed when the pipeline ends, so nothing stale is left on the machine. If the reviewer rejects a run, I do not keep what it did at all. That is wasteful in tokens, but it means I do not spend time on branches the agent never finished itself.

Reflection

Ironically, a tool meant to automate small coding tasks became one of the hardest projects I’ve built. I had heard of many of the concepts involved, but managing live pipelines, building an event-driven queue, and writing my own small broker made me understand them differently. Connecting those ideas to what I already knew was difficult and incredibly rewarding.

Longevity

As for the project’s future, AI tooling changes quickly, but I still think Patchdock has a small niche. At the very least, it works really well at hackathons :). Models are moving towards defining the structure of subagents themselves. With Claude workflows, for example, you can define your own pipeline of agents, while my project is opinionated and somewhat hard-coded to its structure. It is a double-edged sword at best.

Additionally, the philosophy I was building on is that each stage only needs the most important parts: what to fix, what to remove. The content every agent gets is basically defined by its predecessor, which means that if the planner discovered something but did not leave it for the executor, the executor has to discover it again. That is not the best way of solving this problem.

I still use it from time to time, whether at a hackathon or for a small, well-defined change where the main cost is implementation time: renaming variables, updating tests, or making a straightforward fix. It doesn’t need to solve every coding task to be useful to me.