The wrong instruction

Somewhere in your company right now, an agent is paying a frontier model to determine whether a filename ends in .md.

It will get the answer right, most of the time. It will take about two seconds and a fraction of a cent to do it. filename.endsWith(".md") would have taken a microsecond, cost nothing, and been right every time.

This is not really a story about waste; the money is small. It is a story about what happened to that decision on its way to being made. Somebody wrote the review procedure in English:

markdown
You are a repository reviewer. When a push arrives:

1. Look at which files changed.
2. If only documentation changed, do a light review.
3. If dependencies changed, install them, run the test suite, and perform
   a security review.
...

Step 1 is where that .md question gets asked. It is not a judgment call in any interesting sense — it is a string comparison — but English has no way to say so. Every line arrives at the model with identical status: a strong suggestion, evaluated at run time, honored with high probability. Step 3 usually happens. It happens less often when the context is full.

An agent written this way has exactly one instruction available to it, ask the model, and every decision in the program gets compiled down to that instruction, including the ones that were never decisions.

You know the rest of this story because you have lived it. The file grows: two hundred lines, eight hundred, two thousand. Contradictions accumulate the way they do in an untested codebase, except you cannot grep for them, cannot test them in isolation, and cannot tell from a diff whether the edit to paragraph nine broke the behavior described in paragraph four. Your only debugging tool is to change the wording and run it again, which is not debugging so much as divination with a feedback loop. Everyone who has run an agent in production for six months has this file. Nobody is proud of it.

And it does not get fixed by a better model, which is the objection worth answering early. A better model follows English procedures more faithfully — but "more faithfully" is a probability, and you cannot build a control-flow guarantee out of a probability. A model that honors step 3 ninety-nine percent of the time is a much better model and still not an if statement. The gap is not capability. It is category.

We have been doing software engineering in a medium with no if.

The fix is obvious and, as of about a year ago, close to consensus: write the control flow in code and call the model where you actually need judgment. We agree. The problem is that this is roughly a third of what you need, and the remaining two thirds are where every unattended agent we have ever seen goes wrong.

1. The argument that is already over

Before going further we should be precise about what is and is not contentious here, because a version of this argument has already been won by people other than us.

OpenAI's Agents SDK documents two ways to orchestrate — through the LLM, and through code — and tells you to reach for code when you need predictability. Anthropic's agent SDK is largely concerned with sessions, context management, subagents and sandboxed execution rather than with prompting. The OpenHands and SWE-agent lineage made sandboxed execution and lifecycle management first-class instead of incidental. The direction of travel is not subtle.

The frontier labs have already conceded the central point. "Some of your control flow belongs in code, not model output" is not a contrarian position in 2026; it is in the official documentation. If your reaction to the opening was obviously, that's why you write the routing in Python, you are not disagreeing with us. You are agreeing with us and with OpenAI.

So this is not a post arguing that agents should be programs. That argument is over. It is a post about what the agreement leaves undefined — and what it leaves undefined is nearly everything that determines whether the thing still works in November.

2. There is no standard way to write one

The collective version of this problem is worse than the individual one.

Read the agent code at ten companies running agents in production and you will find ten architectures with nothing in common: the two-thousand-line markdown file, a Python orchestrator with hand-rolled retry and a Postgres table for state, a graph library, a canvas of nodes in a browser, a bash script running a coding CLI under nohup. Each represents months of hard-won knowledge about what breaks when nobody is watching. None of it transfers — not the code, not the operational practice, not the debugging technique, not the interview question. Every team starts over in private and arrives somewhere slightly different.

The same was true of deployment in 2012, and the usual story about how it ended is wrong on the facts. Docker did not win on isolation: LXC had existed for years, and Solaris Zones and FreeBSD jails predated it by a decade. Docker won because it produced one artifact and one small set of verbs everyone could agree on. A modest technical contribution and an enormous coordination one — and the coordination is the whole value. A better bespoke deploy script helped one company. A Dockerfile helped everyone who ever read one.

Agents are pre-standard. Everything we collectively learn about running them is trapped inside the team that learned it.

And here is why this follows from the opening rather than being a separate complaint: you cannot standardize prose. That is what prose is. There is no interface for a system prompt to conform to, no artifact to publish, no verb to agree on, no way to tell whether two of them do the same thing. A standard way to write agents requires a language to write them in.

But a language alone is not enough either. Docker shipped a language and an artifact that bound the program to the environment it needed. Those are the two axes — and everything currently on offer has exactly one of them.

3. Where the convergence stops short

The SDKs give you a language and no artifact

When OpenAI's documentation says orchestrate through code, what you get is exactly that: a program in your repository that calls agents as functions. This is correct and we are glad it is happening. It also stops at the process boundary, and three things sit on the other side of it.

  • The environment. Your program calls an agent that is about to run

npm install on a lockfile a stranger edited. Where does that happen, with what isolation, destroyed when? The SDK has no opinion, so you have a Dockerfile and a private convention — the 2012 wiki page, relocated.

  • The schedule. Something has to invoke your program at 3am and survive a

redeploy. A cron entry plus a systemd unit plus a state table you designed yourself is a reasonable answer, and also private to you.

  • The artifact. There is no file. Nothing you can commit and hand to another

engineer that says this is the agent, here is where it runs, here is when it wakes up.

Nobody in 2013 doubted you could run a web server; what was missing was Compose. Writing your orchestration in code is necessary, and about a third of the problem.

The builders give you an artifact and no language

The DAG — nodes in a browser or declared in YAML, executed by an engine — clears the bar the SDKs miss, and fails somewhere else.

Be fair about what it is, because the usual criticism is lazy. A DAG is not a weak programming language; it is a deliberately restricted computational model, and the restriction is the point. Giving up expressiveness buys static analyzability: you can draw it, validate it before it runs, estimate its cost, and enumerate every path because there are finitely many. That is the same trade that makes Dhall, Starlark and eBPF good ideas, and for workloads whose shape is known before execution it is usually right.

Agent work is not that workload, because the shape depends on what you find while doing it. A static graph has no while for "keep fixing failures until tests pass, within six attempts" — you get a retry: 3 field, a fixed-count special case hardcoded into the engine because everyone asked for it. It cannot express recursion, or fan-out whose width is discovered at run time, or a subgraph you parameterize and call from three places, or a variable that later branches read.

So the engine grows a conditional node, then a loop node, then variables, then an expression language inside the string fields, then a DSL, then an SDK in a real language that generates the DSL. Mike Hadlow named this the Configuration Complexity Clock in 2012. Terraform's HCL grew count, then for_each, then dynamic blocks, then CDKTF so you could write TypeScript that emits it; Kubernetes YAML went through Helm, Kustomize, Jsonnet and CDK8s. The clock ends in the same place every time — a real programming language, arrived at five years late with a worse type system than the one available on day one. The instructive counterexample is Airflow, healthiest in the category precisely because its DAG file was Python from the start.

The obvious objection is that Turing-completeness is not a virtue in itself: restricted languages exist because giving up expressiveness buys guarantees. True — and in this domain you lose the guarantee anyway. A DAG's analyzability rests on the graph being known before execution, and the moment it branches on a model's judgment or loops until a condition holds, the picture on the screen has stopped describing what will happen. You are paying the full price of the restriction and no longer collecting the benefit. For agent work, that arrives in about week two.

In a workflow builder, you author the graph. In a program, the graph is the output.

The DAG is a record of one execution — valuable for observability, useless as a source format.

What is missing

A language with no artifact, or an artifact with no language. What nobody has yet is the thing Compose was for services: a declarative artifact, written in a real language, binding what the agent does to where it is allowed to do it and when it wakes up — plus verbs to operate a set of them.

That is the entire scope of agent-compose, and the rest of this post is what it looks like.

4. Dynamic workflow: you draw the line

Start with the language, since section 2 argued everything else depends on it.

An agent should be an actual program, in an actual programming language, in which the model is a callable primitive with a return value. Not a program that generates prompts. Not a graph containing a prompt. A program in the ordinary sense — with variables, branches, error handling, loops and functions — where one of the available calls happens to be think about this.

Here is the reviewer from the opening, written as one. This is the whole thing.

js
const Review = scheduler.z.object({
  summary: scheduler.z.string(),
  risk: scheduler.z.enum(["low", "medium", "high"]),
  blocking: scheduler.z.boolean(),
});

const LOCKFILES = ["package-lock.json", "go.sum", "poetry.lock", "Cargo.lock"];

function classify(files) {
  if (files.every((f) => f.endsWith(".md") || f.startsWith("docs/"))) return "docs";
  if (files.some((f) => LOCKFILES.includes(f))) return "dependencies";
  return "code";
}

scheduler.on("git.push", "on-push", function onPush(event) {
  const { files, sha, branch } = event.payload;
  const kind = classify(files);

  if (kind === "docs") {
    const light = scheduler.agent(docsPrompt(sha, files), {
      agent: "codex",
      driver: "docker",
      sessionPolicy: "reuse",
      outputSchema: Review,
      timeout: "5m",
    });
    return record(sha, kind, light.json);
  }

  const deep = scheduler.agent(deepPrompt(sha, files, kind), {
    agent: "claude-code",
    driver: "microsandbox",      // this one installs dependencies: fresh microVM
    sessionPolicy: "new",
    outputSchema: Review,
    timeout: "20m",
  });

  if (!deep.success) {
    scheduler.log("deep review failed, retrying on a different agent", { sha });
    const retry = scheduler.agent(deepPrompt(sha, files, kind), {
      agent: "codex",
      driver: "microsandbox",
      sessionPolicy: "new",
      outputSchema: Review,
      timeout: "20m",
    });
    return record(sha, kind, retry.json);
  }

  if (deep.json.risk === "high") {
    scheduler.event.publish("review.alert", { sha, branch, ...deep.json });
  }
  return record(sha, kind, deep.json);
});

function record(sha, kind, review) {
  const history = scheduler.state.get("reviews") || [];
  history.push({ sha, kind, risk: review.risk, at: Date.now() });
  scheduler.state.set("reviews", history.slice(-200));
  return review;
}

classify() is the five lines that opened this post. Microseconds, no cost, no variance, replacing a model call that the prose version makes on every push.

Three other things here are load bearing.

The model is a function, not the main loop. scheduler.agent() returns. It has a value you can inspect, a success flag you can branch on, and a failure mode you handle by calling a different agent — four lines of ordinary control flow rather than a framework feature. Retry, fallback and escalation policy stop being configuration and become code, which means they can be read, reviewed and tested like code.

Notice what else sits in the argument list: which agent, which sandbox driver, whether to reuse an environment or materialize a clean one. In most systems those are architectural decisions made once for the whole product; here they are per-call-site parameters, because a documentation review and a dependency audit have genuinely different requirements and there is no reason one program cannot express both. A cheap agent in a warm container for the first; a different agent in a fresh microVM for the one about to run npm install on a stranger's code.

outputSchema is the boundary. This is what makes the combination work rather than merely sound good. Without it a model returns prose, and prose must be re-parsed by something — usually a regex, often another model call — before your program can act on it. With it, the answer arrives as a typed value and deep.json.risk === "high" is a real comparison on a real enum. The schema is the type signature at the boundary between intelligence and control flow, and it is what lets judgment enter a branch without being laundered through a second act of interpretation.

Registration is separate from execution. When the script is saved it is evaluated once, but only to collect its triggers; during that pass scheduler.agent, scheduler.llm, scheduler.exec and event.publish are unavailable, existing only inside handlers at run time. The top level of the program is therefore a pure declaration of when this work happens, and the bodies are the effectful part.

That split should feel familiar: it is terraform plan and terraform apply, and it is React's render and effect phases. Those systems draw the line in the same place because a schedule is a fact about the system that must survive every process which reads it, so it cannot be a side effect of code that might not run again. A trigger id here is a durable identity, not a timer handle. Reload the program, restart the daemon, reboot the host — the registration is still there, because it was never in memory.

This is also the honest answer to the analyzability argument from section 3. We did not decide static structure is worthless; we decided it belongs at the layer where it is achievable. The schedule is statically declared, inspectable and enumerable before anything runs. The body, where the work is genuinely path-dependent, is unrestricted. You get the guarantee where a guarantee is possible and full expressiveness where it is not, instead of pretending the second half can be drawn in advance.

That is what dynamic means here. Not unstructured, and not the model deciding what to do next. The structure is determined at run time, from information that did not exist when the program was written, by code you wrote and can read.

5. The economics of determinism

Determinism is free. Intelligence is expensive.

That sounds like a platitude until you price the three currencies separately. A model call costs latency in seconds, money in fractions of a cent per token, and variance — the probability this particular invocation does something other than what you intended. The first two are on your bill. The third is systematically underpriced, because it does not appear anywhere until it does.

Consider what variance does to a chain. Suppose each model-mediated step does the right thing 97% of the time, which is generous. Two steps: 94%. Twelve steps: 69%. Reliability along a chain of model calls degrades multiplicatively, not additively, and the only lever that meaningfully changes the exponent is having fewer model-mediated steps. Every decision moved from the model into code is not a small saving. It is a term removed from a product.

Which brings back the title. The model call is the most expensive instruction in your language. Nobody would tolerate a language where == took two seconds, cost a tenth of a cent, and was right 97% of the time. We would call that language broken. But it is the instruction set most agents are compiled to, because prose has no cheaper one available.

The routing rule is not complicated, and it is worth stating explicitly because it is the actual craft:

Give it to codeGive it to the model
Decidable from structured data: file extensions, diff size, branch name, exit codesRequires reading unstructured content: does this diff introduce a bug?
Must be identical every time: routing, budgets, security policy, retry countsOpen-ended generation, judgment, weighing tradeoffs
Expensive when wrongCheap when wrong, or where variation is a feature
Will be auditedExploratory

Which produces the rule of thumb we now use for everything:

Use the model where you can't write the if. Everywhere else, write the if.

Now the part that matters, because read carelessly this all sounds like an argument for using less AI. It is the opposite.

Look at what the program in section 4 does with the call it does make. A stronger agent, a longer timeout, a freshly materialized microVM where it is free to install dependencies and execute the code under review, because the environment is disposable and nothing it does can escape. That is a more expensive, more capable, more autonomous invocation than anything the prose version performs — and the prose version cannot afford it, because it has already spent its latency budget on a series of calls that classified a file list, checked whether a string matched, and decided which of its own instructions to follow next.

Determinism subsidizes intelligence. The seconds and dollars you stop spending on decisions that were never ambiguous are precisely the budget that buys a twenty-minute deep security review on the one decision that was. The goal was never fewer model calls. It was concentrating them.

That is the combination we are after — not a compromise where you give up some determinism and some autonomy, but both at full strength in the same file, with the line between them drawn by the person who knows where it belongs.

6. The environment is part of the program

A language is a third of the problem. Section 3 said the other two thirds are the environment and the schedule, and they arrive together in one artifact for a reason worth spelling out.

Before containers, your code was in git and your environment was tribal knowledge. The application was versioned; the machine it needed was described on a wiki page, approximately, by someone who has since left. Docker's real insight was that these are not two artifacts — the environment is part of the program's meaning, and versioning one without the other versions nothing.

Agents make this more acute, not less, because an agent's entire value proposition is that it changes the environment. A function returning text needs no environment worth declaring. An agent that runs npm install, executes a test suite and edits files on disk is substantially defined by what it is permitted to touch. Behavior here is not a property of the model alone:

text
agent behavior = model + tools + environment

Which is why, back in section 4, driver: and sessionPolicy: were arguments at the call site rather than settings in a dashboard. They are not deployment details underneath the program. They are part of what the program means.

So the artifact holds all three: what the agent does, where it is allowed to do it, and when it wakes up.

yaml
name: repo-guard

workspace:
  provider: git
  url: https://github.com/acme/api.git
  branch: main

agents:
  reviewer:
    provider: codex
    image: agent-compose-guest:latest
    driver: docker
    env:
      GITHUB_TOKEN: { value: "${GITHUB_TOKEN}", secret: true }
    scheduler:
      script: |
        # the program from section 4

  nightly-audit:
    provider: claude-code
    driver: microsandbox
    scheduler:
      triggers:
        - name: nightly
          cron: "0 3 * * *"
          prompt: "Audit dependencies for published advisories."

Two agents, two runtimes, one file, committed next to the code they work on. Note that the second has no program at all — when the job really is "run this prompt on a schedule," you declare a trigger and move on. The ceiling is a full programming language; the floor is four lines of YAML. A standard that only serves the hard case is not a standard.

And then the verbs, which we did not invent:

Docker Composeagent-compose
docker-compose.ymlagent-compose.yml
services:agents:
up, ps, logs, downup, ps, logs, down
docker runagent-compose run <agent>
imageguest image
containersandbox session
volumesworkspace (local or git provider)
restart policy, healthcheckscheduler.triggers (cron, interval, event)
runtime: runc, Kata, gVisordriver: docker, boxlite, microsandbox

This table is the least original thing in the project and possibly the most important. Every verb is one you already know, and what you know about ps and logs transfers without being re-taught. That is section 2's argument, cashed: not a better idea than what you use today — a familiar one, which is a different and rarer property.

The sandbox is not a security feature

One row deserves its own paragraph, because it is where the two halves of this post meet.

We build security products for a living, and the reflex in our industry is the approval workflow: show the human each action, let them confirm. That reflex is right for interactive tools and broken for unattended ones. If something must approve every action, then something is the runtime — and if that something is a person, you do not have an autonomous system, you have a person with extra steps. Approval-per-action does not scale to work that happens at three in the morning.

The alternative is not to trust more. It is to make trust unnecessary by bounding what a run can reach. sessionPolicy: new materializes a clean environment for this execution. driver: microsandbox puts a hardware boundary around the one about to install packages from a lockfile a stranger edited. The run ends, the environment is destroyed, and the blast radius was known before it started.

So the sandbox here is not a safety wrapper bolted onto an execution model. It is the execution model, and it is the reason you can stop watching.

7. What you get once it's a program

Everything from here is a consequence rather than a feature, which is the point — none of it had to be designed in.

It is versioned. Behavior lives in git, next to the code it operates on. A change to how the reviewer escalates is a pull request with a diff a colleague can review before it ships. You can bisect it, blame it, and ask why line 31 changed in March.

It is testable. classify() has unit tests, because it is a function. The routing logic — which agent, which sandbox, when to escalate, when to give up — can be exercised in full with model calls stubbed, in milliseconds, for free, in CI. Sit with that for a moment: most of what goes wrong in production agents is not bad model output. It is orchestration. The retry that fired twice, the branch that never ran, the state that was not saved. All of it is now ordinary code under ordinary test, which is a category of assurance prose-based agents cannot obtain at all.

It is schedulable. A program has an entry point and durable state, so something can invoke it and it can survive between invocations. This is where "long-running" stops being an aspiration and becomes an implementation detail. We did not build a feature that keeps agents alive; we stopped needing one, because an agent that is a program with an entry point no more needs to be kept alive between events than an HTTP handler does.

It is operable. Once you have five — reviewer, triage, test maintainer, docs, security monitor — you need to know which are running, what they did last night, and how to stop one. That is ps, logs, down. The control plane exists because programs need somewhere to run, not because we wanted to build a platform.

Here is the part we want to be honest about: none of these four are features of agent-compose. They are properties of anything written as a program rather than as prose. Implement this argument in Python on top of Temporal and you get all four, and we would consider that a good outcome for the argument even though it is a bad outcome for our repository.

The thesis is not that we built the right system. It is that the field needs a standard way to write agents, that a standard requires a language, that the language should be one you already know, and that the artifact has to carry the environment and the schedule or it has not carried anything.

8. What this is not

Not another agent framework. We do not implement an agent loop, a memory system, a tool protocol or a model abstraction, and do not intend to. Codex, Claude Code, Gemini CLI and the rest are good and improving fast, and the last thing this ecosystem needs is another one with a different tool-calling convention. provider: is a field. Bring the agent you already trust.

Not a competitor to the SDKs. If you are writing orchestration in TypeScript with the OpenAI Agents SDK, you are doing the right thing and we are not asking you to stop. The question we are trying to answer starts after your program is written: where does it run, what isolates it, what wakes it up, and what do you hand to the next engineer. A compose file whose script calls into an SDK-built agent is a perfectly sensible thing to have.

Not a replacement for visual builders. If your process genuinely is a fixed sequence known in advance, a DAG tool will get you there faster and let someone who does not program maintain it. Section 3 was an argument about agent work specifically, not a claim that graphs are bad.

Not Temporal. We do not do deterministic replay; agent work is nondeterministic by construction, which is the one thing replay-based durable execution cannot accommodate. Our unit of recovery is the sandbox — re-materialize the environment and run again — not the activity. If you need durable execution with exactly-once semantics over deterministic steps, use Temporal. It is better at that than we will ever be.

Not finished. agent-compose is a public preview. The APIs will change. Scripts are inline only: no script_file, no import, no bundling, and JavaScript is the only host language. Those are real limitations and you should hear them from us.

Not a solution to prompt injection. This is the one we would most like to overclaim on and will not. A sandbox bounds what a compromised run can reach; it does nothing about what a compromised run can conclude. If an agent reviewing a hostile pull request is talked into reporting the diff is clean, the microVM was never the relevant control. Isolation limits blast radius, credential scoping limits what is worth stealing, and neither makes a model's judgment trustworthy when the input is adversarial. Treat unattended agent output the way you treat any untrusted input — including, especially, when the untrusted input is your own agent's conclusion.

9. Bring your agents. Compose how they run.

The reviewer in section 4 is not impressive. Forty lines of JavaScript, most of it the kind of code you would write without thinking — a classifier, a branch, a retry, a bit of state.

That is the argument. Nothing about running an agent unattended for six months should require a new discipline. It should require the discipline you already have: version it, test it, review the diff, declare its environment, give it the narrowest permissions that let it work, and read the logs in the morning. The reason none of that is standard practice yet is not that the field is immature. It is that we spent three years writing this software in a medium that could not support any of it, and the last year discovering that writing it in code is only the first third of the job.

We are not claiming the design in this post is right in every detail. We are claiming the shape is right. An agent is a program: written in a language, stored in an artifact that declares where it runs and when it wakes, executed by a runtime, and operated with verbs you already know.

agent-compose is our attempt, it is open source, and it is early: github.com/chaitin/agent-compose

The reviewer above is in the repo as a working example — a compose file and a script you can run against your own repository in a few minutes. If you build the same thing and find our argument wrong, we would like to hear about it.

Bring your agents. Compose how they run.