Every pull request gets its own isolated stack. Frontend, backend, edge routing, all of it. The URL scheme is designed so AI agents can drive the environment the same way a human user would, no mocks, no test doubles, no special harness. Playwright bots log in, click through flows, hit real APIs, and report results back to the PR. The same substrate powers agentic operators that read logs, correlate incidents, and open follow-up work.
The interesting thing is not the preview environments. Those exist everywhere. The interesting thing is what happens when you design the routing so agents can operate on them.
Two problems, one solution.
Human problem. Reviewing a PR that touches five services is painful. You want to click around a real running version of the branch, not read diffs and imagine. Staging is shared, so you can't test in isolation. Local dev drifts from prod. The gap between "code looks fine" and "feature actually works" is where regressions live.
Agent problem. AI agents doing product QA, incident triage, or exploratory testing need a real environment. Not a mock. Not a synthetic contract test. A running system with real database state, real auth, real edge routing, that the agent can hit with a browser or an HTTP client. If the agent has to negotiate a special harness to reach the code under test, half the value is gone.
Per-PR preview environments solve both. The trick is designing them so the second problem gets solved for free.
Four moving parts.
Any PR carrying a specific label spawns a namespace, a set of deployments, and the routing config for that PR. Delete the label or close the PR, everything gets garbage collected. The controller watches labels, not branches, so PRs that don't need previews don't get them.
The preview URL derives from the branch name via a slug function. feature/new-checkout becomes something like checkout.preview.example.com. Frontend and backend live at the same hostname, path-separated. This matters more than it sounds.
The frontend Axios interceptor rewrites outbound API calls to prefix a per-service path. /api/users becomes /preview/checkout/api-users/api/users on the wire, then the edge gateway rewrites it back to /api/users before it hits the backend service in the PR's namespace. The frontend and backend both think they're talking to a normal environment. The edge does the multiplexing.
Not every service ships in every PR. If the PR only touches the frontend, the backend routes fall through to a proxy that forwards the request to production, adding a header like x-preview-mode: true so prod can distinguish shadow traffic. Cost of a preview drops from "spin up everything" to "spin up what changed."
An agent driving a browser needs to know one thing: the URL. That's it. No API keys for a test harness, no MCP shim, no bespoke inspector protocol. Give the agent the preview URL and it can log in, navigate, click, submit forms, and read the DOM. Same tools, same code, whether the target is prod, staging, or a PR preview.
The rewritePreviewUrl() helper on the frontend and the attachPreviewBePathMappings() interceptor mean that both a human clicking around and a Playwright script hitting fetch() directly get the same routing behavior. The preview is transparent. That transparency is what lets agents plug in without special-casing.
Concretely, this is what a Playwright automation looks like:
await page.goto(previewUrl)
await login(page, testUser)
await page.click('[data-testid=new-checkout]')
await expect(page.locator('.confirmation')).toBeVisible()
That is the entire integration. The agent does not know or care that this is a preview environment. It behaves exactly like a real user session.
Request from frontend: POST /api/checkout/submit
Axios interceptor rewrites to: POST /preview/checkout-branch/api-checkout/api/checkout/submit
Edge gateway sees the prefix, matches route: /preview/{slug}/{service}/*
Rewrites path back down to: POST /api/checkout/submit
Routes to service api-checkout in namespace be-preview-checkout-branch.
If api-checkout is not deployed in this PR (frontend-only change), the route falls through to a catch-all that forwards to prod with x-preview-mode: true. Prod treats it as read-only shadow traffic.
Any time you introduce path rewriting at the edge, CORS preflight becomes a problem. The browser preflights the origin the frontend sees, not the origin the backend sees. Solved with a Lua filter at the gateway that intercepts OPTIONS requests before routing and returns the correct CORS headers based on the requesting origin. Boring. Necessary. The kind of thing that eats a week if you skip it.
The compounding value is that the same substrate serves humans and agents. You don't build one thing for CI and another for AI. It's one system, and the AI usage is a superset of the human usage.
Cost. Namespaces are cheap, but persistent volumes and database sync are not. Amortize with prod-proxy fallback. Only spin up services that changed.
Cold start. ArgoCD sync + image pull + readiness probes is 3-6 minutes per PR. Live with it or invest in warm pools. Warm pools are usually not worth it.
Data. Preview databases either share a snapshot (fast, but PRs collide) or clone per-PR (isolated, expensive). Snapshot with tenant-scoped test users is the pragmatic middle.
Debugging. When the rewrite chain misfires, tracing the request path takes patience. Structured logging at every rewrite point pays for itself many times over.
None of these are novel individually. The value is the assembly, and specifically the URL scheme choice that makes it usable by AI agents without a special harness.
The team was burning cycles on shared staging contention, and the AI ops agents I had built earlier needed a target-rich environment to be useful. Rather than build a testing harness for the agents, I built the routing so the agents could just use a browser like any other user. That decision doubled the value of the preview infra without doubling the cost.
Same pattern applies anywhere you have a small enough number of services to make per-PR isolation practical, and a big enough team that shared staging is a bottleneck.