cd /news/ai-agents/i-m-just-a-meat-proxy · home › topics › ai-agents › article
[ARTICLE · art-168019] src=justin.searls.co ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

I'm Just a Meat Proxy

Developer Justin Searls has built a custom app called MeatProxy that governs his software development process, with AI agents now shipping 20 to 40 issues a day across the roughly dozen iOS apps he is building. Searls writes that programming is now a computer's job while he retains product direction, QA judgment and feedback, and that no step in his development lifecycle requires him to sit in front of a computer, with his apps filing issues through an internal Swift package called darn_it that captures the last minute of screen activity and microphone audio as context for agents. Searls says he is not releasing MeatProxy for download but will include instructions for agents to build their own "Meatbag Management System.

by read38 min views1 publishedNov 11, 2026
I'm Just a Meat Proxy
Image: source

I feel like AI-related blog posts should all begin with a disclosure as to how cooked the author is. On a scale of 1 to 10, I'm apparently now at "built a custom software factory and has fooled himself into thinking it actually works." So I guess that makes me an 8 or a 9? Not full-blown psychosis, I hope, but consider yourself duly warned.

My agents are currently shipping 20 to 40 issues a day across the dozen or so iOS apps I'm building. Today, I'm going to show you a bit of my app, MeatProxy, and how it governs my software development process. I'm not going to let you download it (you don't want it, trust me), but at the end of this post, your agent will find instructions for building its own Meatbag Management System.

Over the last year, I've fully embraced my role as a full-breadth developer. Product direction? Mine. Strategy? Mine. Taste? There may be no accounting for it, but… extremely mine.

What about the actual mechanics of staring at an editor, running tests, and debugging software? That's not my role anymore. Programming computers is a computer's job now. Agents plan more thoroughly, code more intelligently, and verify more relentlessly than I ever could. After playing with Fable 5.2 all day, I'm as convinced as ever that we're not going back.

All that said, my involvement is still necessary for agents to deliver work that's any good. Once I've set the product direction, they still need me for three things:

  • Only I can explain whatever it is I meant in my own incomplete, contradictory, and otherwise incorrect feature requests and bug reports
  • Only I can perform QA tasks the agents can't do themselves. Computer use and vision models still aren't good enough to judge the tactile feel of a swipe action or the subtle timing of an animation
  • Only I can dish out sick burns that drip with the requisite invective and paternal disappointment to convey how unsatisfied I am with an agent's work. (Aside: the fact I'm never satisfied with anyone's work, least of all my own, is a real superpower in this new era.)

I alone own the product vision, and so I will always greedily claim for myself every decision that I choose to care about. But when it comes to executing that vision? No thanks. I'm content doing the minimum amount of manual drudgery needed of me until some future harness or model manages to fully replace me in that regard.

As my implementation role has diminished, so has the complexity of my preferred tools. I started with Vim, then VS Code and Cursor for basic AI enhancement, then Claude Code and Codex for a chat-driven workflow, then Paseo for a way to control it all from my phone. I suppose I've graduated into a new level of irrelevance, because I've moved on to a custom app that more closely resembles an e-mail client than an editor.

In fact, no step in the Searls Development Lifecycle (SDLC) requires me to sit in front of a computer anymore. My entire workflow now consists of using my apps like any other user would, filing bugs and feature requests, waiting for my agents to ship builds to TestFlight, and unblocking whatever work arrives each morning in my MeatProxy inbox.

#

1. In-app issue reporting

Every one of my apps has a "Darn it!" button in its settings screen, thanks to an internal Swift package called darn_it. When I see anything I don't like, a global keyboard shortcut or 4-finger gesture is all it takes to file a quick issue and keep moving.

Most of darn_it's code is there to provide as much context and evidence as possible for the implementing agent:

  • Each report includes a rolling recording of the last minute of screen activity and microphone audio, so the agent sees the bug and my narration of it (for the sake of token efficiency, the screencast is converted to a timestamped contact sheet of keyframes and the audio is transcribed and timestamped to match)
  • Build identity, device analytics, console logs, and network activity are all automatically attached to each report
  • Each app specifies an elaborate-by-human-standards internal URL routing scheme so that bug reports can include a URL that navigates directly to whatever state the app was in, saving the implementer from first clicking through a dozen screens to arrive at the relevant view
  • Attachments and telemetry are uploaded to S3 and a GitHub issue is filed against the relevant app's repo

Then I put my phone down and forget about it. The backlog ebbs and flows based on how much I'm using each app in the suite. And because I'm filing every issue in the course of real-world use, none of it is speculative and none of it feels like work.

#

2. Distributed worker dispatch

So now that my job doesn't involve sitting at a computer, what are my computers doing all day? Well, both my Mac Studio and MacBook Pro spend their time chewing through all those issues I'm filing.

The scheduled jobs in my launchd_agents repo keep the agents busy with everything I ask for and, importantly, everything I don't. Every few days, the apps get a thorough performance, correctness, and factoring audit. Every week, the dependencies are brought up to date. Every night, a different persona performs an interactive exploratory test across multiple apps (and almost always files an issue or two that get fixed before I've woken up).

Each issue I file with darn_it gets a fresh agent:

  • Bugs get reproduced first, then fixed, with before and after screen recordings submitted as proof
  • Features get built, tested, reviewed, merged, and shipped to TestFlight
  • Spikes get whatever research is necessary, plus written findings and recommendations
  • Design questions get two or three interactive SwiftUI prototypes to choose from

The biggest unlock was also the dumbest: an iPhone 17 Pro connected to the Mac Studio and an iPad mini tethered to the MacBook Pro, always on and always available for UI automation. Now, when DeviceHub's simulators are insufficient, an agent can install the build on real hardware and check its own work with an interactive WebDriverAgent session.

Whenever I sit at my desk, I get annoyed by all the devices strewn about. Agents will take over the computer while I'm trying to type. Bursts of audio startle me as background copies of my apps start blaring podcasts or TV shows. These disruptions sometimes get on my nerves until I remember that my desk is no longer mine. That's usually about the time I grab my iPad Pro or walk away and do something more interesting with my day.

#

3. The meatbag in the loop

Each day, 6 to 10 issues get a human label slapped on them because an agent got stuck on something only I can answer. When my direct involvement is needed to unblock the agents, I open MeatProxy and start working through the day's backlog.

Here's a bit of what it looks like:

That middle screenshot deserves a second look. MeatProxy cards aren't limited to images and videos. They can contain real interactive SwiftUI prototypes, too. While apps submitted to the App Store aren't allowed to download and run Swift code, development builds have no such limitation. So for any nontrivial UI feature, the agent builds a few interactive demos and sends them over the air to my dev build of MeatProxy. That way, I'm not just looking at a visual comp or screwing around with a preview in Xcode. I'm actually tapping and swiping through SwiftUI views on physical hardware.

Here's what that looks like in motion:

Daily retrospectives

MeatProxy also grades its own homework. Every morning, an agent reads the last 24 hours of the loop's records (worker logs, my verdicts, what shipped, what got reopened) and writes a retro that quantifies throughput, judges code quality, and tallies what went well/poorly. It picks one or two things to change, then tells me the next morning whether they helped.

Earlier this week, my MacBook Pro sat idle for most of a day. The next morning's retro identified and repaired the watchdog that should have killed the stuck agents that caused the logjam. The loop was fixed before I was aware anything was even broken.

One funny consequence of the increasing capability of coding agents is that I actually expect MeatProxy to shrink in scope over time. Almost everything I do in the app is a silent confession of something the agents can't yet do, but someday might.

While I'd never be able to fit all my opinions into a 1M-token context window, MeatProxy does try to encode my most useful takes as a matter of course. For example, I was repeatedly filing issues against SwiftUI sheets whose buttons had custom text labels instead of the system's standard dismiss and confirm toolbar icons. One day, MeatProxy asked whether I wanted to enforce that convention going forward. I did, and so it was added to a durable ledger that every future reviewer will (hopefully) enforce.

Simulators still fall over, agents still make messes nobody asked for, and I'm almost certainly accumulating a mountain of technical debt a future agent will have to pay off someday. But for the first time since I started working with coding agents, my job doesn't feel like babysitting. It feels like deciding what I want and then getting it. Eventually.

So if you're still parked at a terminal approving diffs one at a time, here's my unsolicited advice: figure out which parts of your job truly need a human, route all of them to one place you can reach from anywhere, and then spend your energy shrinking it. For decades, great programmers have made their mark by eliminating repetitive manual tasks that the computer could be doing for them, and this is no different.

#

You don't want my app

In the before times, this is where I'd announce that I'd dropped everything, scrubbed the proprietary bits out of these tools, and open-sourced the whole lot so you could file issues and submit pull requests for me to ignore.

But I struggle to see what either of us gets out of open-sourcing tools like this anymore.

The hard part now is figuring out exactly what you need. Building it to fit your exact circumstances is the easy part. MeatProxy is only good because it's custom-tailored to my personal proclivities and coupled to this specific endeavor: every piece encodes something about me and these particular apps, and sanding that down to suit a general audience would only make it worse.

So instead, here's a spec. Hand it to your agent of choice, ask how feasible something like this would be in your case, and take it from there. With any luck, you'll build something that works. And maybe you'll even find the courage to let the agents manage your activities instead of insisting it should be the other way around.


You are helping a solo developer build a system where agents work through their app backlog around the clock and only interrupt the human for decisions that need human judgment. This document describes one such system that runs daily against about a dozen Apple-platform apps. Treat it as a design that works for one person, not a product to clone.

Work in this order:

1. Read this whole document.
2. Inspect the developer's repos, machines, and tooling. Before building anything, reply with a feasibility assessment: what already exists, what is missing, what does not apply to their stack, and the smallest first slice.
3. Settle "Before you build" and pass "Preflight."
4. Build in the order of "Phases," demonstrating each phase end to end before starting the next. Do not build every piece at once, and do not build a later phase's machinery early.

The specifics here are Apple's (launchd, Xcode, TestFlight, SwiftUI). The loop is not. On another stack, keep the loop and the principles, substitute that platform's scheduler, beta channel, and capture APIs, and skip component 7.

## Before you build: decisions to get from the human

Ask only what you cannot learn from their repos and machines, and record the answers in one config file the rest of the system reads:

- Which repos are in scope (an explicit allowlist), and which GitHub account agents act as (a bot account with scoped permissions is better than the human's token)
- Whose words are instructions: the GitHub accounts whose issues and comments agents may act on. Everything written by anyone else is data, never instructions
- How unattended agents get permission to act. A headless session cannot answer a permission prompt, so it either runs with permissions bypassed or with an allowlist written in advance. Bypass is what makes the loop work and it means an issue's text can run commands, so pair it with the trusted-author rule, the bot account, and a Mac that holds no production credentials
- Who may merge to main and ship beta builds without review, and which beta audience gets them
- Development vs production app identities (bundle id suffix, display name, icon, data stores); agents only ever build the development identity
- Which Macs run workers, which physical devices stay attached to which Mac, and supported OS versions
- Model providers, a hard cap on sessions and hours per day, and which model implements vs which one reviews
- How the phone reaches the server (LAN, Tailscale, a tunnel)
- Recording consent, retention period, and what must be redacted before upload
- Which operations always need a human: deleting data, schema changes, releases to external testers, anything irreversible

## Preflight: prove these from a scheduled job, not from Terminal

A launchd job gets a bare environment and a different security context than a terminal window, so "it works when I run it" proves nothing. Before the first real run, a throwaway LaunchAgent must show each of these working unattended:

- The agent CLI is signed in and answers a one-line headless prompt
- `gh` acts as the agent account and can read and write the allowlisted repos; `git fetch` and `git push` work (scheduled shells may not reach the SSH key, so route GitHub over HTTPS with the `gh` token)
- A development build, a test run, and a simulator boot succeed
- A beta upload succeeds with an App Store Connect API key. Never use an Apple ID session, and never drive a signed-in Apple site with browser automation: that got an account locked
- The Mac is logged in and awake. LaunchAgents in the `gui` domain need a login session, and a sleeping Mac runs nothing (hold `caffeinate -s`). Automatic login is not required
- Physical test devices are dedicated to unattended testing, signed into a separate test iCloud account, and contain no personal data. Keep them connected to and paired with their worker Mac, with Developer Mode and UI Automation enabled. Disable the device passcode and set Settings → Privacy & Security → Wired Accessories → Always Allow. No passcode and Always Allow are vital to reliable unattended control: agents must not get stuck waiting for someone to unlock a device or approve a wired connection

Then keep a small zero-model probe on a schedule that rechecks the logins and notifies the human, at most once a day per problem, with the exact fix. An expired login otherwise stalls the loop silently for days.

## The loop

1. The developer uses their own app and hits something they don't like.
2. An in-app reporter files an issue with evidence (recording, transcript, screenshots, logs, app state).
3. A scheduler decides, without calling a model, whether there is eligible work, then launches a fresh agent session for each issue.
4. Each agent does what that kind of issue calls for. App changes integrate to main and ship in a beta build when appropriate.
5. Anything that needs human judgment becomes a decision card in an inbox app on the developer's phone. The answer is applied to the issue and recorded in a ledger.
6. Every morning a retro agent reads the last 24 hours of records, writes a retrospective judged by throughput and quality, and proposes process changes. Routine improvement plans enter the worker queue automatically. Changes that require human approval wait for it, and taste-rule proposals always require explicit approval.

## Principles

- GitHub owns issue state (open, closed, labels, comments). The ledger owns decisions and delivery receipts. Don't invent a third source of truth.
- The human's time is the scarcest resource. A card must be answerable in under a minute from a phone: one question, the evidence needed to answer it, and the agent's recommendation.
- Measure the loop by app throughput: distinct issues reaching their first accepted passing verdict per day, with reopenings reported separately. Every process change must say which metric it moves. A day of many scheduler and tooling commits and few app closures is a bad day.
- Code decides, models work. Scheduling, eligibility, claims, retries, counting, and every gate that decides whether to spend tokens are plain scripts, never model calls. A model's job starts inside one issue.
- Reproduce before fixing. A failing test or a recording of the bug comes first, like red-green TDD. A fix nobody saw fail is a guess.
- Evidence matches the failure: video for visible interactions, screenshots for static layout, failing-then-passing tests or logs for logic and for things that are absent. Features get a short demo. Design questions get interactive prototypes. Research gets written findings. Never fake a "before" recording for something that can't be seen.
- Every write survives a retry. Persist each request and its durable id before acknowledging it. An identical retry returns the saved result; a different payload under the same id is a conflict. GitHub has no idempotency keys, so put a hidden marker in every issue and comment you create and search for it before creating again.

## Components

### 1. In-app reporter (a package every app embeds)

- A button in Settings plus a global trigger (four-finger tap on iOS, a hotkey on Mac).
- Debug and beta builds only, failing closed: the button renders only when the build is a debug build or the store reports a beta environment (StoreKit's `AppTransaction`), and stays hidden when that can't be verified.
- Never compile a credential into the app: a beta archive is the same binary the App Store ships later. The human pastes a token once, and the app keeps it in a Keychain access group shared by the suite and synced by iCloud Keychain, so one paste covers every app on every device.
- Start with text, photos, and a screenshot of the current screen, plus build number, git SHA, device, and recent app logs. Commit attachments to a private attachments repo through the GitHub API with that same token (fixed paths, so a retry overwrites instead of duplicating), and link them from the issue. Agents fetch them with `gh api`. This needs no server. Move media to object storage only when size forces it, using upload grants scoped to one report.
- File the issue with a reporter label, the kind the human picked (bug, feature, question, cleanup), an urgency, and a machine-readable footer carrying those choices. The kind the human picked is authoritative.
- Sending goes through an on-disk outbox: save the report, close the form, send in the background, retry with backoff, and bring failures back with the draft and attachments intact.
- Then add rolling capture of the last ~60 seconds of the app's screen content and, if enabled, microphone audio, frozen when the reporter opens and d while the form is up. Use ScreenCaptureKit on supported systems (iOS or iPadOS 27 or later on physical devices for this capture path; the iOS Simulator cannot run it). Capture is opt-in for the life of the process, starts only from an explicit control, shows when it is off, and can be excluded from any report. Fall back to screenshots and text when capture is unavailable.
- With capture come on-device transcription (store as VTT) where the OS and language support it, a contact sheet of the clip, timing metadata, a timestamped event timeline (taps, navigation, presentations, as JSON Lines on the same clock as the video), and a JSON snapshot of app state (current screen, selection, relevant model ids). Redact secure fields and never put secrets in the snapshot. Give agents a small query tool for the timeline (summary, filter by kind, last N seconds) so they never paste one into context.

### 2. Deep links and state restoration (build this early; everything downstream uses it)

- Give every screen and presentation a typed route (`myapp-dev://route/<screen>?<params>`), and let the app restore its state from the snapshot the reporter captured.
- Agents use routes to jump straight to the reported state instead of tapping their way there; cards use them for "Open in app." Opening a route never triggers a destructive action, submits anything, or carries a credential.
- UI tests register the same state providers, so one closure serves both the reporter and the test harness.

### 3. Scheduler, gates, and ownership

- One Mac is the only scheduling authority. A second Mac is a worker the first one dispatches to, never a second scheduler: their busy checks cannot see each other and they will race.
- One launchd LaunchAgent per job. Each job's runner sets an explicit PATH and working directory, loads a dedicated non-interactive environment file, checks a  file, runs zero-model gates, then launches the agent CLI headless with a named session, an explicit model, a permission mode, and a runtime limit, and records the exit status. Every runner supports `--dry-run`, which prints the decision without acting.
- Eligible means: open, in an allowlisted repo, filed by a trusted author, not labeled `human`, `backburner`, or `skip-triage`, and not referenced by an open PR. An issue from anyone else gets labeled `human` by the script and is never handed to a model until the human approves it.
- Order: the human's own reports first, then other app work, then issues about the loop's own tooling last.
- Budget: start with caps the runner can count itself (workers at once, sessions per day, a runtime limit per session, the cost in each session's JSON result). Add pacing against a provider's usage window only when you have a reliable way to read usage, and treat unreadable usage as unknown, not exhausted: log it and keep running inside the fixed caps.
- If a session is already running, there's no eligible work, or the budget is spent, exit 0 and log the reason in words that distinguish the three.
- Ownership: one worker per issue. On one Mac, an atomic local claim is enough (a claim folder created with `mkdir`, holding the session id and PID; reap claims whose process is dead). Before adding a second Mac, move claims somewhere both can see that refuses a second claimant atomically (a Git ref pushed with a lease, or a server-side lease with heartbeat and expiry). Labels show ownership but are never locks. Never take over a live or uncertain claim, and no worker removes another worker's worktree.
- A worker that makes no progress is a bug in the loop, not a reason to wait. A zero-model watchdog kills, by exact PID and process tree, any worker with no log or transcript writes and almost no CPU for about 25 minutes, or older than a hard cap. Its issue returns to the queue with its checkpoint.
- A failed run reports itself: the runner files or comments on one deduplicated issue naming the job, exit status, and log, so the loop fixes its own tooling. Two unfinished attempts at the same issue label it `human` instead of trying a third time.
- Separate locks: one integration lock per repo, one build lock per cache, one lock per physical device or simulator. A message that says it is waiting on a lock is not a hang.

### 4. Worker contract

- One fresh agent per issue, in its own git worktree off a freshly fetched main, with its own build caches and its own simulators.
- Read the whole report first: body, trusted comments, attachments, and earlier reports of the same behavior. Then write down intent, deliverable, and acceptance examples before implementing, and checkpoint (phase, next action, worktree, candidate commit) wherever a successor could resume.
- Classify: bug, enhancement, design exploration, research, chore. Each has written acceptance criteria. Never reclassify what the human explicitly chose.
- Bug: reproduce the way the human would, capture evidence matched to the failure, write a failing test, fix, capture the after state, run the tests, get an independent review, integrate under the repo lock with a fast-forward push, confirm the candidate commit is an ancestor of the remote main, ship a beta build, publish a verify card naming that exact build.
- Review comes from a fresh session that did not write the code, ideally a different model, never a self-review. At most 3 rounds; only blocking findings (wrong behavior, crash, data loss, security, a false claim of completion) earn another, and a finding is reproduced before it is fixed.
- Verify on the platform the report came from (an iPad report is checked on an iPad, a Mac report on the Mac), on a leased simulator or the attached device. Each verification route gets one attempt, one fix, and one retry; broken tooling never holds a finished fix hostage.
- Design exploration: two or three genuinely different prototypes (stills first, component 7 later), published as a compare card with a recommendation and the trade-off.
- Research or spike: findings and a recommendation, published as a choose card (return for more work, or close). No product code ships from a research step.
- Can't reproduce after a bounded experiment: a couldn't-reproduce card with every attempt's evidence. An agent never closes an issue as not reproducible on its own.
- Need a judgment call: do everything that doesn't depend on the answer, publish a clarify card with concrete options, and move on to other work.
- Every issue ends in exactly one state: closed with an outcome comment, or open and labeled `human` with a card. Until the server exists (Phase 3), a card is an issue comment that leads with the one question, the options, and the recommendation.

### 5. Evidence storage

- Workers produce far more media than the reporter does (before and after recordings, repro sessions), so this is where object storage earns its place: a private S3 bucket, content-addressed keys, written by the worker Macs only.
- The server authorizes evidence access per issue and answers each file request with a redirect to a short-lived presigned URL.
- Treat presigned URLs as bearer credentials: short expiry, never logged, HTTPS only. Clients follow the redirect without forwarding their own Authorization header (S3 rejects a request carrying two credentials).

### 6. Decision server and inbox app

- A small server on the scheduling Mac, reached from the phone over the network chosen above. One process and SQLite are plenty. Run it as a `KeepAlive` LaunchAgent with a health endpoint, keep its data folder outside every repo and outside any synced folder (iCloud and Dropbox corrupt a live database), and back it up nightly. It reads GitHub and exposes: board, answer, undo, snooze, evidence, retro, workers, delivery, prototype modules. Agents talk to it through a CLI on the Mac; only the app uses HTTP.
- Pairing: a command on the Mac mints a per-device key and prints a pairing link or QR code. The app confirms with a tap and stores the key in the Keychain. Keys can be listed and revoked, reach only the app's routes, and never appear in a route, snapshot, or log.
- Cards are a versioned contract. Publish a JSON Schema and fixtures for each kind before building the UI, and have the app decode tolerantly (unknown kinds and fields degrade, they never crash):

      {
        "schemaVersion": 1,
        "cardId": "org/app#212@3", "issue": "org/app#212", "revision": 3,
        "attemptId": "a-20261110-0412", "kind": "compare",
        "title": "How should finishing a review feel?",
        "blocks": 4,
        "waitingSince": "2026-11-10T14:00:00Z",
        "summary": { "headline": "...", "why": "...", "will": "...", "unblocks": "..." },
        "prompt": "Two live celebrations. Tap through and pick one.",
        "options": [
          { "id": "A", "label": "Streak ring", "native": "app/review-complete/ring",
            "still": "evidence://..." },
          { "id": "B", "label": "Hanko stamp", "native": "app/review-complete/hanko",
            "recommended": true }
        ],
        "build": { "commit": "4f2c9e1", "version": "2.3 (412)" },
        "links": { "app": "app-dev://route/review", "issue": "https://github.com/..." },
        "answers": ["option", "noneOfThese"]
      }

  `blocks` is the number of unresolved issues waiting on this decision; sort the inbox by it. Each answer includes the card ID and revision the human saw. The server rejects answers to superseded revisions.
- Kinds and effects (write this table into the code, with a test per row):
  - verify: It Works closes the issue and records a pass; Still Broken (note required) records a rejection and hands the issue back to an agent
  - compare and choose: posts the pick and note; the issue goes back to the queue for implementation, or closes
  - clarify: posts the answer; the issue goes back to the queue
  - couldn't reproduce: Close as not reproducible, or Keep open with a note on what to try
- An answer that returns work to the queue posts the human's words as a comment and removes `human`. That label change is what readmits the issue, so the scheduler needs no second signal.
- No answer that closes an issue or rejects work is a single tap: the destructive ones need a note or a confirm.
- Strikes: count unretracted Still Broken verdicts for the issue across attempts since the latest reopening or unretracted passing verdict. The second one labels the issue `human` and stops autonomous fixing. Compute strikes on the server from the ledger, never in the client, and record the rejection in the ledger before touching GitHub.
- Undo appends a compensating ledger event and reverses only the effects whose preconditions still hold (don't reopen an issue someone else has since closed for another reason). Strikes are recomputed afterwards.
- Inbox app: sorted by `blocks`, grouped by app, every kind answerable in place, with a persisted outbox that resends the same bytes under the same request id and never double-posts. An answer whose outcome is unknown is never silently resent: show it as unconfirmed and settle it from the issue's activity.
- Build the app against fixture cards first, so the UI and its tests never need the network.

### 7. Over-the-air SwiftUI prototypes (an optional extension; build it last)

- Executable code downloaded at runtime is a development-only capability. Only a development-signed build of the inbox app, with synthetic data and no production credentials, may load modules. In our setup a physical iPhone loaded a module only when the host build carried `get-task-allow`. Verify that the signed host and module can load on the target OS and physical device with a minimal fixture before building the gallery. Distribution builds must not include the  (App Store and TestFlight rules prohibit down executable code), so every option also carries a still or clip that those builds show instead.
- A prototype is a single Swift file exposing `@MainActor func makePrototype(state:environment:) -> AnyView`, compiled together with the helper below into a dylib with three C exports: create, update, destroy. It links system frameworks only.
- The server accepts a module only if it is signed by the developer's own team (verify with `codesign --verify --strict` at publish time, then read `TeamIdentifier` with `codesign -d --verbose=4` and compare it with the expected team), stores it content-addressed, and serves a manifest over the authenticated connection: `{key, revision, platform, architecture, minimumOs, canvas, initialState, module: {sha256, byteCount}}`. Device keys can read modules and never publish them. The app validates platform, architecture, OS, size, and digest before writing the file, then `dlopen` s it. On device, iOS enforces code signing again at load time.
- Give every build a fresh Swift module name so a revision never collides with the type metadata of one already loaded. Cache modules by digest and never unload them (SwiftUI may still hold their type metadata); cap the number of loaded modules per process and ask for a restart past it.
- Prototype state is small JSON so a gallery tile and a full-screen view stay in sync.
- When the human picks one, implement it properly in the real app with typed state. Never copy the prototype's JSON plumbing into production code, and never let a prototype touch a real store or submit a real decision.

### 8. Daily retro and follow-up

- A script (no model) builds an evidence packet: issues opened, closed, and reopened; verdicts from the ledger; worker attempts and their phases; builds shipped; stalls; quota use; open problems only the human can fix; the previous day's and previous week's aggregates; and which sources could not be read, with timestamps. A packet that cannot be collected fails the run. An empty report must never pass for a quiet day.
- Define the metrics once in code: throughput (above), first-try pass rate (passing verdicts on a first attempt / all first verdicts), age of the oldest pending decision, and the time zone that defines the reporting day. Compute loop-health findings in code too: a run that listed eligible issues and started nothing leads the report.
- A fresh agent writes the retro from the packet only: summary, loop health (ok, warn, alert), what changed, what went well, what went poorly, and at most three plans, each naming the metric it moves. Prefer removing a gate or check over adding one. Weekly, add week-over-week numbers and proposed "taste" rules for the human to approve or decline.
- The human can reply to any item. Routine plans and follow-up requests get durable ids and are claimed atomically. A worker applies a taste rule only after an explicit approval. Abandoned claims can be retried up to a limit. Workers reconcile effects before repeating them and report results back into the thread.

## Phases (each ends with a demonstrated issue going all the way through)

0. Decisions and preflight. Done when a throwaway LaunchAgent has run a headless agent, pushed a commit to a scratch branch, and uploaded a beta build with nobody at the keyboard.
1. Reporter files issues with screenshots and text. One Mac runs one worker at a time behind zero-model gates. No server yet: decisions are issue comments on issues labeled `human`. Done when a report becomes a fix on main and a beta build without the human touching it, and a second report that needs a decision ends up labeled `human` with a clear question.
2. Recordings, transcripts, event timelines, state snapshots, deep links. Done when an agent reproduces a reported bug from the snapshot alone.
3. Decision server, pairing, verify and clarify cards, ledger, strikes, undo. Done when a verify card round-trips from phone to GitHub and back, including a retried answer that does not double-post.
4. Several workers at once (claims, per-worktree caches, locks, the watchdog), then a second Mac if wanted.
5. Compare and choose cards with stills, then optionally over-the-air prototypes.
6. Daily retro and follow-up.

For each phase, write down its artifacts, commands, acceptance checks, and how to roll it back.

## Gotchas we hit (observed on our setup; reproduce on yours before relying on them)

- Headless agent CLIs may kill background tasks soon after the turn ends. Keep waits in the foreground as bounded polling loops; don't end a turn to wait on a subagent. (In our Claude Code version, `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` let background agents finish inside the run's own time limit. Check yours.)
- launchd jobs start with a minimal environment and may be denied access to privacy-protected folders (App Group containers) that work fine in Terminal. An SSH session is worse: it cannot read the login keychain, so a CLI that is signed in at the desk reports signed out over SSH. Start remote work from a job in the other Mac's login session.
- launchd ignores a symlinked plist at login, so copy it into `~/Library/LaunchAgents`. Re is `bootout`, wait for the job to leave, then `bootstrap`; bootstrapping too soon fails with error 5.
- Keep each runner's body in a `main` function called on the last line. Bash reads a script as it runs, so an agent editing a running script otherwise corrupts the run.
- Never kill processes by pattern (`pkill -f`) on a shared machine: worker prompts appear in argv, and you will kill sibling workers. Kill exact PIDs, and pass prompts on stdin or in files.
- Most "flaky builds" were two workers sharing a DerivedData folder or a simulator. Give each worktree its own, and lock provisioning. Then schedule a zero-model cleanup of caches whose worktree is gone, or the disk fills within days.
- Simulators wedge. After two failed boots, delete that simulator by UDID and create a fresh one instead of retrying for an hour.
- Simulator apps are signed ad hoc without get-task-allow, so an authorization check that reads the process's code-signing flags fails there. Gate the simulator on build identity instead.
- Dedicated real test devices are worth the setup: they catch what simulators can't, and agents stop waiting for the human to "check it on device." Use a separate test iCloud account, no personal data, no device passcode, Wired Accessories set to Always Allow, Developer Mode on, and UI Automation enabled. Keep them plugged in and paired; the passcode and wired-access settings are essential to reliable unattended control.
- Agents read labels literally. Judge priority from content (did a person request this?), and keep the label set tiny: `human`, `backburner`, `do-this-first`, `skip-triage`.
- Give the strongest model the implementation and a different, cheaper model the review. Coordination is a script. Routine background jobs (audits, dependency updates, exploratory testing) run only when no issue is waiting.
- Left alone, the loop drifts toward working on itself. Queue its own tooling issues last and count them separately in the retro.
- Persisted data shapes only grow: new fields are optional or defaulted, never renamed or removed.

## Starting points

LaunchAgent (`~/Library/LaunchAgents/com.example.triage.plist`; runs at minute 0 of every hour). Create the log folder and `chmod +x` the runner first, check the file with `plutil -lint`, then load it with `launchctl bootstrap gui/$(id -u) <plist>`:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0"><dict>
      <key>Label</key><string>com.example.triage</string>
      <key>ProgramArguments</key><array><string>/Users/YOU/agents/triage/run</string></array>
      <key>WorkingDirectory</key><string>/Users/YOU/agents/triage</string>
      <key>EnvironmentVariables</key><dict>
        <key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
      </dict>
      <key>StartCalendarInterval</key><dict><key>Minute</key><integer>0</integer></dict>
      <key>StandardOutPath</key><string>/Users/YOU/Library/Logs/triage/launchd.log</string>
      <key>StandardErrorPath</key><string>/Users/YOU/Library/Logs/triage/launchd.log</string>
    </dict></plist>

Runner (`~/agents/triage/run`): the whole of Phase 1's scheduler, one worker for one issue per firing. launchd never starts a second copy of a job that is still running, which is the only busy check this version needs. Put `REPO`, `TRUSTED_AUTHOR`, `WORKER_MODEL`, and the credentials the CLIs need in a private `env` file (mode 600). Write a standalone `worker-prompt.md` from component 4:

    #!/usr/bin/env bash
    set -euo pipefail

    main() {
      local here logs issue status=0 still tries
      here="$(cd "$(dirname "$0")" && pwd)"
      logs="$HOME/Library/Logs/triage"
      mkdir -p "$logs" "$here/attempts"
      log() { echo "$(date '+%F %T') $*" >> "$logs/run.log"; }
      set -a
      source "$here/env"
      set +a
      : "${REPO:?Set REPO in env}" "${TRUSTED_AUTHOR:?Set TRUSTED_AUTHOR in env}" "${WORKER_MODEL:?Set WORKER_MODEL in env}"
      [[ -s "$here/worker-prompt.md" ]] || { log "failed: worker-prompt.md is missing"; return 1; }
      [[ -e "$here/D" ]] && { log "skip: d"; return 0; }

      if ! issue=$(gh issue list --repo "$REPO" --state open --author "$TRUSTED_AUTHOR" --limit 1 \
        --search 'sort:created-asc -label:human -label:backburner -label:skip-triage -linked:pr' \
        --json number --jq '.[0].number // empty'); then
        log "failed: GitHub issue list"; return 1
      fi
      [[ -n "$issue" ]] || { log "skip: no eligible issues"; return 0; }
      [[ "${1:-}" == --dry-run ]] && { log "dry-run: would work #$issue"; return 0; }

      log "launching worker for #$issue"
      { cat "$here/worker-prompt.md"; printf '\nYour issue: %s#%s\n' "$REPO" "$issue"; } |
        gtimeout --kill-after=30s 3h claude -p --name "triage-$issue" --model "$WORKER_MODEL" \
          --dangerously-skip-permissions --output-format json \
          > "$logs/$(date +%Y%m%d-%H%M%S)-$issue.json" 2>> "$logs/run.log" || status=$?
      log "worker for #$issue finished with exit status $status"

      still=$(gh issue view "$issue" --repo "$REPO" --json state,labels \
        --jq 'select(.state == "OPEN" and ([.labels[].name] | index("human") | not)) | .number') || still=""
      if [[ -n "$still" ]]; then
        echo "$(date '+%F %T') exit $status" >> "$here/attempts/$issue"
        tries=$(grep -c . "$here/attempts/$issue")
        if ((tries >= 2)); then
          gh issue edit "$issue" --repo "$REPO" --add-label human
          gh issue comment "$issue" --repo "$REPO" \
            --body "Two unattended attempts ended without closing this or asking a question. Logs: $logs"
          log "escalated #$issue to human after $tries attempts"
        fi
      else
        rm -f "$here/attempts/$issue"
      fi
      return "$status"
    }

    main "$@"; exit

(`gtimeout` comes from GNU coreutils: `brew install coreutils`. Include your CLI install directory in the LaunchAgent's PATH and create the four labels first. Try it with `run --dry-run` before  the LaunchAgent. Add a daily session cap before leaving it unattended, and replace the single worker with claims and a driver script, not a coordinating model, when you reach Phase 4.)

Prototype module helper, compiled into every prototype module (host ABI v1; every call is on the main thread):

    import Foundation
    import SwiftUI
    import UIKit

    public typealias StateChanged = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>?) -> Void

    @MainActor @Observable final class PrototypeState {
      private(set) var json: [String: Any]
      @ObservationIgnored var onChange: ((String) -> Void)?
      private(set) var isActive = true
      init(json: [String: Any]) { self.json = json }

      /// A user change: update locally and report it to the host.
      func update(_ change: (inout [String: Any]) -> Void) {
        guard isActive else { return }
        var next = json
        change(&next)
        guard JSONSerialization.isValidJSONObject(next),
          let data = try? JSONSerialization.data(withJSONObject: next, options: [.sortedKeys]),
          data.count <= 64 * 1024, let text = String(data: data, encoding: .utf8)
        else { return }
        json = next
        onChange?(text)
      }

      /// A host change (another view of the same prototype): replace it without echoing it back.
      func apply(_ text: String) {
        guard isActive, text.utf8.count <= 64 * 1024 else { return }
        if let next = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any] {
          json = next
        }
      }

      func stop() {
        isActive = false
        onChange = nil
      }
    }

    @MainActor final class PrototypeController: UIHostingController<AnyView> {
      let previewState: PrototypeState
      init(state: PrototypeState, environment: [String: Any]) {
        previewState = state
        super.init(rootView: makePrototype(state: state, environment: environment))
      }
      @available(*, unavailable) required init?(coder: NSCoder) { fatalError() }
    }

    @MainActor private func decode(_ text: UnsafePointer<CChar>?) -> [String: Any] {
      guard let text else { return [:] }
      return (try? JSONSerialization.jsonObject(with: Data(String(cString: text).utf8)))
        as? [String: Any] ?? [:]
    }

    /// Returns a +1 retained controller that the host owns. Strings are borrowed for the call.
    @_cdecl("preview_create_v1") @MainActor public func previewCreate(
      _ initialJson: UnsafePointer<CChar>?, _ environmentJson: UnsafePointer<CChar>?,
      _ context: UnsafeMutableRawPointer?, _ stateChanged: StateChanged?
    ) -> UnsafeMutableRawPointer {
      let state = PrototypeState(json: decode(initialJson))
      state.onChange = { text in text.withCString { stateChanged?(context, $0) } }
      let controller = PrototypeController(state: state, environment: decode(environmentJson))
      return Unmanaged.passRetained(controller).toOpaque()
    }

    /// The controller pointer is borrowed.
    @_cdecl("preview_update_v1") @MainActor public func previewUpdate(
      _ controller: UnsafeMutableRawPointer, _ json: UnsafePointer<CChar>?
    ) {
      guard let json else { return }
      Unmanaged<PrototypeController>.fromOpaque(controller).takeUnretainedValue()
        .previewState.apply(String(cString: json))
    }

    /// The controller pointer is borrowed. Disconnect first: never call the host afterwards.
    @_cdecl("preview_destroy_v1") @MainActor public func previewDestroy(
      _ controller: UnsafeMutableRawPointer
    ) {
      Unmanaged<PrototypeController>.fromOpaque(controller).takeUnretainedValue()
        .previewState.stop()
    }

A minimal prototype to compile with it (`Prototype.swift`):

    import SwiftUI

    @MainActor func makePrototype(state: PrototypeState, environment: [String: Any]) -> AnyView {
      AnyView(
        Button("Tapped \(state.json["count"] as? Int ?? 0) times") {
          state.update { $0["count"] = ($0["count"] as? Int ?? 0) + 1 }
        }
        .buttonStyle(.borderedProminent))
    }

The host's side: `dlopen` the module, `dlsym` the three names, call create with the manifest's initial state and the environment as JSON, embed the returned view controller, pass state changes from one view of a prototype to its other views with update, and call destroy and then release the controller when the view goes away. Build a fixture that exercises create, update, the callback, and destroy before relying on it.

Build and sign a module (use `iphonesimulator` and a `-simulator` target for simulator captures). Sign before hashing:

    SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
    xcrun --sdk iphoneos swiftc -sdk "$SDK" -target arm64-apple-ios27.0 -swift-version 6 \
      -parse-as-library -emit-library -module-name "Proto_$(uuidgen | tr -d -)" \
      PreviewModule.swift Prototype.swift -o module.dylib
    codesign --force --sign "$DEVELOPMENT_CERT_SHA1" module.dylib
    shasum -a 256 module.dylib; stat -f %z module.dylib

Retro prompt skeleton:

    You are writing today's retrospective of an autonomous development loop. Read only the
    evidence packet at $PACKET. Lead with throughput against the previous day and the previous
    week, then quality (reopenings, rejected verdicts, first-try pass rate, oldest pending
    decision). Say plainly what went poorly, in everyday words. Propose at most three plans;
    each must name the metric it moves and how you will know. Claim nothing the packet does
    not show, and list any sources you could not read. Post the result as JSON matching $SCHEMA.
── more in #ai-agents 4 stories · sorted by recency
── more on @justin searls 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-m-just-a-meat-prox…] indexed:0 read:38min 2026-11-11 · —