Makeitlive takes a zip file and turns it into a live URL, driven entirely by a chat conversation — no CLI, no dashboards, no infra forms. That sounds simple from the outside. Underneath it, it's two independent Python processes, an AI agent that gets three tries to fix a broken deploy before it asks for help, and a real-time event stream that keeps the browser in sync with whatever the agent is doing. This is a walk through how it's actually built.
The shape of it
The system splits into three pieces with very different jobs:
Browser (React chat UI)
→ Cognito (SRP auth + token refresh)
→ API (FastAPI) (bearer id_token, SSE ticket)
→ S3 (upload the source zip)
→ Postgres (read/write project + run state)
Worker (long-running loop)
→ Postgres (poll for work, SKIP LOCKED)
→ S3 (download the zip)
→ Railway (create/deploy/fetch logs)
→ Claude API (diagnose a failed deploy)
→ Postgres (NOTIFY on every state change)
The API is stateless request/response, run under uvicorn. The worker is a long-running loop processing one deployment at a time per process. They never call each other directly — Postgres rows and pg_notify are the only thing they share, which means either one can restart or scale out independently without the other knowing or caring.
The deploy-agent's phase pipeline
The actual "figure out how to deploy this" logic lives in a set of phases, each its own module, each with a narrow job:
- Analyser — deterministic static inspection of the uploaded project: finds
package.json, detects the framework from dependencies, works out install/build/start commands and the port, flags monorepo ambiguity. No LLM involved. Raises an error if it genuinely can't make sense of the project. - Static-fix — only runs if analysis fails. A bounded Claude turn (capped at 2 attempts) with file read/edit tools, just enough to make the project analysable — adding a missing start script, for example.
- Planner — turns the analysed facts into an ordered deployment plan (runtime, steps, port). A pure function, no LLM, no side effects.
- Preflight — deterministic checks before anything gets deployed: monorepo ambiguity, a missing
.envwhen a.env.exampleexists. Asks the user a plain-language question when it needs to — never an LLM for this phase, since the questions are mechanical. - Deploy/diagnose loop — capped at 3 attempts: create and deploy to Railway, poll status, and on failure fetch the build and runtime logs and hand them to a bounded Claude turn with file tools to fix the problem, or decide to give up and ask the user instead.
That last point is the actual "agentic build/fix loop" from the product idea: errors get diagnosed and fixed automatically where that's possible, and the user is only interrupted for the things a code edit genuinely can't resolve — a missing external dependency, an ambiguous project layout, a config value only the user actually knows.
Talking to a process that might not exist yet
The tricky part of a chat UI over an asynchronous background job is that the "agent" the user is talking to isn't always running. When the deploy-agent needs input it can't get from a live terminal, it doesn't block — it raises an error that parks the run in an "awaiting input" state with the question attached, and tears down whatever Railway infrastructure it had provisioned so far. The user's answer gets recorded and the run is re-enqueued. On replay, the agent matches previously-recorded answers back to the same question by exact kind-and-text match, rather than trusting that the run will take the identical path a second time — so a run that branches differently on replay doesn't get handed an answer meant for a different question.
The same "durable state, no coroutines" philosophy shows up in how a crashed worker is handled: nothing is lost, a claimed run just carries a time-boxed lease, and another worker picks it up once that lease expires. The runner explicitly checks for — and cleans up — a Railway service a previous, crashed attempt may already have created, rather than assuming a clean slate.
Keeping the browser in sync, live
Every state transition the worker makes writes an event row to Postgres and fires pg_notify in the same transaction. The API turns that into a stream the browser can subscribe to over Server-Sent Events: replay anything since the last event the client saw, then block on LISTEN, repeat. Since EventSource can't send an Authorization header, the client first makes one authenticated call to mint a short-lived, single-use ticket, then opens the event stream with that ticket as a query parameter instead of a header.
SSE over WebSockets was a deliberate choice: no custom framing, works over plain HTTP, reconnects natively on the client, and sidesteps the header problem entirely rather than needing a bespoke auth scheme baked into the browser API.
A few decisions worth explaining
Some choices in this system trade a "more standard" architecture for fewer moving parts, and it's worth being upfront about what that costs:
- Postgres as the only broker. The job queue (claimed with
SELECT ... FOR UPDATE SKIP LOCKED, so multiple workers can poll the same table without double-processing a run), the pub/sub layer, and all durable state live in one database instead of adding Redis, SQS, or a separate message bus. Fewer systems to run and reason about, at the cost of holding a Postgres connection open for every live SSE stream. - Conversational-only UX. There's no settings screen, no environment-variables form — the preflight phase's handling of a missing
.env.exampleand the diagnose loop's questions both go through the same chat surface a traditional tool would use for a config form. - Multi-tenancy via one Railway project per user. Each user's first successful preflight lazily provisions their own Railway project and a project-scoped API token, encrypted at rest before it's stored. The shared account-level token is used only to create that initial project and clean up an orphaned service left behind by a reclaimed run — day-to-day deploys use the tenant's own scoped credentials.
Where it actually runs
Makeitlive is live on AWS. The landing page and the app frontend are each an S3 bucket behind CloudFront; the API runs on AWS Lambda via the Lambda Web Adapter, fronted by a Function URL in streaming response mode, since the SSE endpoint needs a genuinely long-lived streamed response that neither API Gateway's 29-second timeout nor CloudFront's response buffering can provide. That Lambda choice wasn't the original plan — the design specified App Runner, which turned out to have quietly stopped accepting new AWS customers, discovered only once a real deploy against a fresh account tried to use it.
The worker runs on ECS Fargate Spot, two tasks running concurrently so two deployments can be in flight at once, with no load balancer in front of it since nothing needs to route to it — it only makes outbound calls. There's deliberately no NAT Gateway anywhere in the stack, the single largest avoidable fixed cost at this scale: the worker sits in a public subnet with security groups, not subnet placement, doing the actual access control. Secrets live in SSM Parameter Store rather than Secrets Manager, and DNS stays on Cloudflare rather than migrating to Route 53, pointed at each CloudFront distribution as a DNS-only record so it doesn't fight CloudFront's own TLS termination.
None of this was frictionless to get right. The first real deploy surfaced problems no terraform plan could have caught — an environment variable read by the code but never actually set by any infrastructure task, GitHub's OIDC token format changing for repositories created after a certain date, a Docker image that never installed the CLI tool the deploy step shells out to. Every one of those got root-caused against real, live AWS and GitHub state, not guessed at.
What's next
The deploy-agent core still lives in its own module, imported directly by the backend rather than folded in as a proper package — that consolidation, along with a couple of smaller cleanup items, is the main piece of acknowledged unfinished work. The system is live and handling real deployments today; this is where it stands right now, not a finished state.
makeitlive