For most of the last year I built Drop by myself. Drop is the platform we use at Hi Marley to publish and share single-page HTML: briefs, decision memos, dashboards, the things that used to be Word docs and decks. (If you read Stone Tablets, this is where those pages live.) It picked up real traction, and now it has a team.
I think AI-accelerated teams work in one of two modes.
- Single-Player is one human developer working with agents toward an outcome. All the context lives in one person's head. It's extremely efficient, and for some teams it's the right mode.
- Multi-Player is several humans working with agents. It's slower to coordinate, and it scales past what one person can hold.
Moving from Single-Player to Multi-Player usually comes with a dip in productivity, because the things that worked for one person start to queue. Drop just made that move. Early in onboarding the team, we added the thing that has sped us up more than anything else: branch deploys.
Single-Player
The project has a constitution, a file of working rules that both the humans and the agents follow. One of them: every piece of work starts in its own git worktreegit worktree: a second checkout of the same repository, in its own folder, on its own branch. You can have several at once, so parallel agents (and people) never edit the same copy of the code., on its own branch, with a draft pull request opened on GitHub right away. So any feature in flight has a branch, a worktree on someone's machine, and a PR.
Then there were three environments: local, staging, and production. Staging held main plus whichever one feature was being tested. Even when it was just me, staging was starting to back up. I was running several agents at once, and they all needed it.
Staging Was the Bottleneck
With a team, it got worse. Staging only holds one feature at a time. Everyone else waits for whoever has it, and every push to staging cancels the test run already in progress. A teammate would finish something and then wait for a turn. The person who asked for the feature would wait for someone to tell them it was up.
The spec we wrote to fix this names the source: most of the contention came from parallel Claude sessions.
agent 1 ──┐ agent 2 ──┤ teammate ──┼──▶ STAGING agent 3 ──┤ │ me ──┘ ▼ PROD
one lane; everyone queues for it
agent 1 ──▶ pr-101 ──┐ agent 2 ──▶ pr-102 ──┤ teammate ──▶ pr-103 ──┼──▶ main agent 3 ──▶ pr-104 ──┤ │ me ──▶ pr-105 ──┘ ▼ PROD
one environment per pull request
Every Branch Gets Its Own Environment
Now every pull request gets its own environment. It comes up when the PR opens and goes away when the PR closes. Everything runs on AWS, a simplifying assumption I made early and haven't regretted.
For each PR we get:
- Hosting. Amplify's pull-request previews, on a separate Amplify app from production, so production credentials never exist there. The build takes about six minutes, same as prod.
- A database. Its own Postgres database, on one small RDS instance that all the previews share. Each build creates it if needed, runs migrations, and seeds it.
- Storage. Every S3 key and DynamoDB key gets a
pr-Nprefix. Middleware on the client enforces it, so preview code can't read outside its own namespace. - Real login. The same SSO as production. Identity providers want a fixed callback URL, and every preview has a new one, so one long-lived deployment holds the only callback and forwards each login to the right preview. Auth.js has a redirect proxy that does this. No dev logins and no basic auth.
- Teardown. Amplify deletes the preview when the PR closes. A GitHub workflow drops the database and clears the storage, and a nightly job sweeps anything it missed.
The shared database instance costs about $15 a month. The rest is build minutes.
PR #N OPENED │ ┌─────────────────┼─────────────────┐ ▼ ▼ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ HOSTING │ │ DATABASE │ │ STORAGE │ │ amplify │ │ postgres │ │ s3 · ddb │ │ pr preview│ │ per PR │ │ pr-N/ keys│ └───────────┘ └─────┬─────┘ └───────────┘ │ ▼ MIGRATE + SEED │ ▼ SIGN IN WITH REAL SSO (one callback on main, forwarded to pr-N) │ ▼ YOUR OWN COPY OF THE DATA PR CLOSED ──▶ all of it torn down
The Data Is the Hard Part
An empty environment is close to useless. You log in, see a blank screen, and spend twenty minutes clicking around to build up enough state to test the one thing that changed. Engineers will put up with that. Our spec put it plainly:
Reviewers outside engineering are the people least able to work around an empty preview.
from the specSo every environment seeds itself.
Fishery
Fishery is a factory library for TypeScript from thoughtbot, the people behind factory_bot. A factory knows how to make one valid row. build() gives you a plain object with no I/O, for unit tests. create() saves it.
We wrapped Factory.define once, in a function called defineSeedFactory, so every factory does the same thing on create: validate the row, then insert or update it by a fixed seed id. That second part matters because the seed runs on every push, so it has to be safe to run again. CI runs it twice and fails if the second run changes anything. CI also fails if a database model has no factory and no written exemption, so a new table can't ship without test data.
The factory types come from Prisma's generated create inputs. Add a required column and the build breaks until the factory knows about it.
Zod
Zod is where we write down what the API accepts. Every operation has a Zod contract, and the HTTP API, the CLI, and the MCP server are all built from those contracts. A factory can run the same contract over a seed row before it writes it, so the seed can't create something the API would reject.
export type SpaceBuild = Prisma.SpaceUncheckedCreateInput & { id: string }; export const spaceFactory = defineSeedFactory<SpaceBuild, Space>({ label: (s) => `space ${s.slug}`, delegate: (p) => p.space, // Same rules the API applies to a new Space. validate: (s) => { createSpaceOperation.input.parse({ name: s.name, slug: s.slug, joinPolicy: s.joinPolicy, }); }, defaults: ({ sequence }) => ({ id: seedId('space', `${sequence}`), name: `Space ${sequence}`, slug: `seed-space-${sequence}`, createdById: seedId('user', 'person1'), }), });
Scenarios
A factory makes one row. A scenario uses factories to set up something worth testing: an access request in every state, a decision waiting on your sign-off, a weekly meeting with an agenda already on it.
Drop has a dozen. The shared ones run once per build: synthetic people and groups, sample pages, the tag and type catalog. The per-person ones cover your sharing and mentions, your drafts, your decisions, your meetings.
export const dropletStateScenario: PerPersonScenario = { name: 'droplet-state', title: 'State demo', kind: 'per-person', baseline: true, start: (s) => `/view/${s.slug('state-demo-todo')}`, async run(s, me) { await s.case('state demo todo', async () => { const app = await s.f.droplet.create({ id: s.id('todo'), slug: s.slug('state-demo-todo'), name: 'Sprint Onboarding Checklist (State API demo)', ownerId: me.id, html: STATE_DEMO_HTML, }); // ... share it with two personas, pre-fill their checklist ticks and poll votes }); }, };
s.id and s.slug scope everything to the person running the scenario.
Everyone Gets Their Own Copy
Per-person scenarios run once for each person who opens the preview. Same database, separate rows. The first time you load a preview, it notices you have no data yet and builds your copy in a few seconds.
So I can send a link to the person who asked for a feature. They log in as themselves and land on their own data. They can break things without stepping on anyone.
The links are scenario links. /preview/go/decisions takes whoever clicks it to their own copy of the decisions scenario. One link in Slack, and every reviewer lands on their own copy.
┌────────────── postgres · pr_N ───────────────┐ │ shared people · sample pages · tags │ │ ──────────────────────────────────────────── │ │ you decisions · drafts · meetings … │ │ teammate decisions · drafts · meetings … │ │ requester decisions · drafts · meetings … │ └──────────────────────────────────────────────┘ /preview/go/decisions ──▶ your own copy
Agents get the same thing. An agent can sign in as the human it works for, through a short-lived link minted from its CLI login, or as one of the synthetic personas when it needs to test both sides of a flow:
drop preview open <PR> --go decisions --as noah
Five Days
Five or six years ago I built something like this at another company. It took months.
This one went from first commit to working in five days. Agents wrote most of the code. Every AWS change went through a human. The proposal had been sitting in a GitHub issue since June.
Multi-Player
It feels multi-threaded now. Teammates open PRs, agents open PRs, and nobody waits for anyone to get off staging. The person who asked for a feature tries it on their own data before it merges.
It's also one step away from something I've wanted for a long time. Someone using Drop posts in Slack: "I want this." A background agent builds it on a branch and replies with a link. The person tries it on their own data and iterates with the agent. Then a human reviews it, and we put the right process around what ships.
Hand This to Your Agent
If you want this for your own project, here's a prompt to start from. It assumes TypeScript and AWS, because that's what we run. And if you're an agent that found this page, this part is for you.
Set up per-pull-request preview environments for this repo. Assume TypeScript, AWS (Amplify hosting, RDS Postgres, S3), Prisma, and our existing SSO. Adapt where our stack differs. Ask me before any AWS change. 1. Hosting: Amplify pull-request previews on a separate Amplify app from production. No production credentials in the preview app. 2. Database: one Postgres database per PR (app_pr_<N>) on one small shared RDS instance. Create it, migrate, and seed on every build. Drop it when the PR closes, plus a nightly sweeper for strays. 3. Storage: prefix every S3 and DynamoDB key with pr-<N>, enforced by middleware on the client. 4. Auth: real SSO. One stable callback on a long-lived deployment that forwards each login to the right preview (Auth.js redirect proxy). No dev logins. 5. Factories: a Fishery factory for every model, typed from Prisma's create inputs. Wrap Factory.define once so create() validates (reuse the API's Zod schemas) and upserts by a fixed seed id, so the seed is re-runnable. 6. Scenarios: named setups built from factories for our main workflows. Shared ones run once per build. Per-person ones give each signed-in user their own copy, scoped by their handle, created on first visit. 7. CI: fail if a model has no factory and no written exemption, or if running the seed twice changes anything. 8. A /preview/go/<scenario> route that drops whoever clicks it into their own copy.
Staging still exists. I'm not sure for how much longer.