My sdd workflow works because I’m there: I start each phase, read the plan, and check the result. The next question was what happens when nobody is there. Can a ticket in a project tracker turn into a merge request on its own, without anyone copying it into a terminal, and without taking the agent’s word that it works?

That’s agent-platform: a platform for building coding agents that work unattended. One Rust engine runs every agent. Each agent is a folder of configuration and skills, and each repo opts in with one file. The agent harness inside is Claude Code today, but very little of the platform depends on that: it could as well be Pi or Codex.

The split

The whole design comes down to one rule: if a step must always happen the same way, it’s engine code. If it needs judgment, it’s the agent.

The engine (deterministic)The agent (judgment)
Clones the repo and runs its setupReads the ticket, the spec and the review threads
Holds every tokenHolds none
Enforces turns, time and budgetDoes the work its skills describe
Runs the gateCalls only the tools it’s granted
Delivers: push, merge request, repliesAsks a person when it’s stuck

I didn’t write an agent loop. Coding harnesses already have one, with tools for reading, searching, editing and running commands that are built and tuned. A framework with its own loop around the model would mean rebuilding all of that, and a repo would lose the way developers already configure their agent locally: instructions, skills and hooks.

An agent-native SDLC

Where the agents fit in the development cycle: a spec, reviewed until it’s right, then the code, reviewed by the review agent and a person until it’s right.

Where the agents fit in the development cycle: a spec, reviewed until it’s right, then the code, reviewed by the review agent and a person until it’s right.

The agents don’t replace the development cycle, they slot into it. The steps are the ones a team already has (a ticket, a spec, a merge request, a review), and the artifacts are the same ones people produce: a spec in the repo, a merge request with tests. Three agents run on the engine today:

  1. Someone adds a spec label to a ticket. The spec agent reads the ticket, follows the cause across the repos it may read, and opens a merge request with a spec.
  2. A person reviews it. If it needs changes, they just comment, and the agent picks the comments up. Once it’s merged, the ticket is marked ready.
  3. Someone adds a code label. The code agent starts from the merged spec, writes failing tests, then the code, and opens a merge request only if the gate passes.
  4. The review agent is asked to review it, and posts one thread per finding, next to any human comments.
  5. A person comments when they want a round of fixes, or merges. Once every repo’s merge request is in, the ticket is done.

Every step that moves the work forward is a person’s decision. The code agent may ask the review agent for a review, but only a person can send the code agent back to work on it, so two bots can’t talk themselves into a loop.

A comment is a wake-up signal, not a job. The router waits for a few quiet minutes after the last comment, so a reviewer can finish writing before anything starts, and then one run answers every unresolved thread: eight comments make one run, not eight. A reviewer who doesn’t want to wait can mention the agent directly, with an instruction ("@code-agent add more tests for the error cases"), which counts as a comment and a start at once. Each reply is marked as handled in the thread itself.

That’s also why there’s no database. The tracker is the memory: a status label on the ticket says where the work stands (working, needs info, failed, ready, done), the threads say what’s been answered, the merge request’s footer records what each run cost, and the logs hold the transcript.

The infrastructure: one run, end to end

A code request through the infrastructure, from the label on the ticket to the merge request.

A code request through the infrastructure, from the label on the ticket to the merge request.

The tracker and the git host send webhooks to a small router on AWS: API Gateway in front of two Lambdas, with an SQS FIFO queue between them. The first Lambda verifies the webhook, drops the noise (edits, bot comments, system notes) and queues it, grouped by ticket so one ticket’s work is handled in order. It never calls anything else, so it always answers within the trackers’ timeouts.

The second Lambda, the dispatcher, checks what the webhook alone can’t tell: is the repo set up for agents, is the spec merged, is another run already working on this ticket? Each refusal is a comment on the ticket, so nobody is left guessing. A failure it can’t turn into a comment, like AgentCore being unreachable, is retried and then lands in a dead-letter queue.

If everything checks out, the dispatcher starts an AgentCore Runtime session: a microVM running the engine, with Claude on Bedrock as the model. The session ID is derived from the ticket and the agent, so a second request for the same work lands in the same session instead of starting a parallel run.

The agent manifest

An agent (a flavor, in the code) is a folder: a manifest, plus a folder of instructions and skills shaped exactly like the harness’s own user folder. The engine copies that folder in as the harness’s configuration, so a skill works the same in a run as on a laptop.

The manifest covers only what the skills folder can’t. Here’s the code agent’s, slightly simplified:

name: code-agent
runtime: { envs: [base, ruby, rust, python] }
claude:
  model: sonnet
  small_model: haiku
  tools: { allow: [Read, Edit, Write, Grep, Glob, Bash, Skill] }
capabilities:              # progress, ask_user and report_result are always on
  git:
    tools: [reply_to_thread, attach]
    reason: "answers review threads and attaches screenshots, only if the gate passed"
  browser:
    tools: [browser_navigate, browser_snapshot, browser_take_screenshot]
setup: ["mise install"]
gate: { tests: optional, on_fail: { retry_with_claude: 3 } }
limits: { max_turns: 200, run_timeout: 2h, max_budget_usd: 10 }
deliver: { branch: "agent/{ticket}-code" }

The other two are variations on it. The spec agent gets the strongest model, may only edit files under specs/, may read up to five other repos from a map of services, and brings its own gate: a spec file with valid front matter must exist. The review agent is read-only: no shell, no edits, one tool to post a finding, and no gate, because it pushes nothing.

There’s deliberately no workflow language. No conditions, no loops: the manifest picks from step types the engine implements, and templates only fill placeholders like {ticket}. The moment a YAML file grows an if, it’s rebuilding CI.

Each flavor deploys as its own AgentCore runtime, with its own IAM role and its own bot. That’s a security decision more than an organizational one: a prompt injection in a ticket comment read by the review agent must not reach a token that can push code.

The project manifest

A repo opts in with .agent/config.yaml: how it builds, how it tests, and what the gate checks.

stack: ruby
setup:
  - "bin/setup --skip-server"
  - "npm ci"
test:
  all:
    backend: "bin/rails test"
    frontend: "npx vitest run --coverage"
  file:
    "test/**/*_test.rb": "bin/rails test {file}"
    "app/javascript/**/*.test.ts": "npx vitest run {file}"
lint: ["bin/rubocop"]
gate:
  coverage:
    - { report: coverage/coverage.xml }

test.all is the full suite, one gate check per entry. test.file says how to run a single test file of each kind, which the red-before-green check needs. The engine refuses any key it doesn’t know, by name, so a typo fails before a run instead of silently turning a check off. And the same gate runs on a laptop with one command, so a team can see what the agent will be held to before it ever runs.

Environments, with mise

stack: ruby picks an image. The images are layered:

  • agent-base: the engine, the pinned harness binary, every flavor, and the settings no flavor or repo may change. No toolchain.
  • env-base: adds mise, Postgres, Redis, a headless browser for screenshots, and the build dependencies for compiling a toolchain.
  • env-ruby, env-python, env-rust: env-base plus the recommended versions of that language, installed with mise and baked in.

mise is what makes one image per language enough. The image bakes the versions most repos use, and when a repo pins another one, mise install adds it at the start of the session. The run uses exactly the versions a developer gets locally from the same mise.toml.

AgentCore shaped the rest. There’s no Docker inside the microVM, so the repo’s services run as plain processes, started by the repo’s own setup. And an image must stay under 2 GB, which is why the browser is the headless shell only and the prebuilt Rubies are stripped of their debug info. A release refuses to publish an image over the limit, instead of finding out at deploy time.

The MCP bridge

Ticket text is untrusted input, and the agent can read a lot of code. That makes prompt injection a real concern, so the agent never holds a credential.

Two Unix users enforce it. The engine runs as one and reads its tokens from Secrets Manager. The harness, the repo’s commands and the browser all run as another, which can’t read the engine’s environment or memory. The engine switches users through file capabilities rather than root.

Everything the agent needs from the outside goes through an MCP server hosted by the engine itself, on localhost, with a token minted for that run. It’s the only way the agent acts beyond its files, and it registers only the tools the manifest grants. The harness is started with a strict MCP config, so a repo can’t bring its own servers either.

Tools come from a capability catalog, and each has a kind:

  • Read tools run right away: search other repos, clone one read-only, report progress.
  • Intent tools are recorded, not executed: reply to a review thread, attach a screenshot, comment on a ticket. The engine sends them at delivery, only if the gate passed. Each needs a stated reason in the manifest.

A capability can be built into the engine (the git host, the tracker, chat, monitoring) or be an upstream MCP server the engine proxies, holding the credential on the agent’s behalf. Either way the agent sees one MCP server and no secrets. It’s the same pattern as the runner in HiveLabs, tightened with intents.

The result: the agent can’t open a merge request with failing tests, and it can’t reply “fixed” to a review comment without a push behind it.

The gate

The gate runs after the agent says it’s done, before anything leaves the sandbox. It’s the same idea as the gates in sdd, except the engine runs it, so there’s nothing to skip.

  • Tests: every entry of the repo’s suite, on the final code, all of them even when one fails, so the report shows everything.
  • Lint: whatever the repo considers its bar.
  • Red before green: every new or changed test file must fail on the code from before the change.
  • Coverage: each report can’t drop below its baseline, measured before the agent starts, unless the agent gives a reason, which goes on the merge request.

This only works because the agent owns its test loop. The usual setup is to push and let CI report what broke, which means a new round for every failure and no way to write a failing test first. Here the sandbox has the database, the cache and the browser the repo needs, so the agent works tests first, runs migrations and takes screenshots of visible changes. CI still runs after the push, and it’s still the authority. The gate is what keeps unproven work from reaching it.

If the gate fails, the engine resumes the agent’s session with the failing checks and their output. If it still fails, the work isn’t thrown away: it goes on a draft merge request, marked as not ready, with the failing checks at the top. No reviewer is asked, and none of the intents are sent.

Two lessons came out of building it:

A test can fail for the wrong reason. Red before green sounds simple: run the new test on the old code and check that it fails. But if the old checkout is missing the frontend’s dependencies, every frontend test fails there, including one that asserts nothing. So the check first runs a control, an unchanged test of the same kind, on the old code. It must pass; if it doesn’t, the failure is a setup problem, not proof. Proving that a test fails also needs proof that it could have passed.

A check with no baseline always passes. If the engine can’t read a coverage report before the run, there’s nothing to compare against, and “coverage didn’t drop” is true by default. So a missing baseline stops the run before the agent starts, with an error naming the report.

Asking a person

Nobody watches a run, so a question can’t wait for an answer inside the sandbox. The harness’s own way of asking the user is turned off in the settings no flavor can change. The only way to ask is a platform tool that ends the run.

The engine, not the agent, decides where the question goes. Early on, with nothing worth keeping, it’s a comment on the ticket. With work worth keeping, it’s a draft merge request with one thread per question, so open questions block the merge. Either way the ticket is marked as needing info, someone answers where the question is, and the next run reads the answer. A fixed rule is predictable and testable; letting the agent pick isn’t.

The harness is a detail

Here’s what’s specific to Claude Code: one module that starts the CLI headless and reads its streamed output, a settings file baked into the image, and the shape of the skills folder. Everything else sits next to the harness, not inside it. The gate runs commands. The bridge is plain MCP, which every serious harness speaks. Delivery is git and an API. The manifests describe a run, not a model.

Swapping in Pi or Codex would mean a new adapter, a different settings file and porting the skills, not a new platform.

Testing the agents, not just the code

The engine has ordinary tests, with a scripted stand-in for the harness so a full run can be checked without calling a model. The agents themselves get evals: sample repos in Rails, Python and Rust with tasks to do, tests the agent never sees, and a read-only judge. A change to a skill is measured against those, not judged on one lucky run.

Everything also runs on my own machine. Floci, an open-source AWS emulator, stands in for API Gateway, Lambda, SQS, Secrets Manager, S3, ECR and the AgentCore control plane, and a small local server answers AgentCore’s invoke API by starting the session’s container. The engine has no special path for any of it: an endpoint URL is the only difference. So the end-to-end tests run the real binaries: a webhook goes through the Lambdas and the queue, a session starts, the agent works through a local model gateway, and the test waits for the merge request.

Where it stands

It’s early, but every piece has run for real: the router as Lambdas, the agents in AgentCore on Bedrock, and the whole flow end to end at home on Floci. The limits in the manifests are starting values, to tune once there’s a history of real runs. The repository is private for now.