Vibe Coding a CRUD App: From One Prompt to a Live URL
Every platform team eventually needs the same boring app: a list of things, a detail page per thing, a form to edit it, and some notion of who is allowed to see what. Clusters, machines, tenants, environments, feature flags. It's never the interesting part of the job, and it's never small enough to skip.
This time I wanted to see how far I could get by typing a few sentences and letting Claude Code do the rest. The thing was Talos Linux clusters and the machines inside them, which is the inventory I was keeping in a spreadsheet after building the on-prem cluster. Here's the full transcript of what I typed, what came back, where it stalled, and what I had to do myself.
The Setup: Skills, Not Prompts
The trick that made this work wasn't a clever prompt. It was a skill: a folder of markdown instructions, templates and shell scripts that tells the agent how this kind of app is built here. I used a set of crud-* skills installed as a Claude Code plugin:
| Skill | Owns |
|---|---|
crud-app-scaffold | the spec, project layout, root files, orchestration of the others |
crud-backend-go | Go + Gin + GORM conventions, server wiring, errors, pagination |
crud-auth | email/password login, JWT in an HttpOnly cookie, CSRF guard, admin users |
crud-authz | RBAC roles plus ReBAC relation tuples: owners, editors, viewers, teams, parent inheritance |
crud-resource | adding one resource end to end, cloned from a golden project example |
crud-frontend-react | Vite + React + shadcn/ui + TanStack Query shell and conventions |
crud-deploy-render | Dockerfile, render.yaml, deploying with Task |
crud-quality-gates | gofmt, golangci-lint, eslint, tsc, vitest, osv-scanner |
The skill pins the stack, the folder layout, the error envelope, the pagination contract, even the comment markers where new code gets inserted. The agent copies and adapts. It does not get to invent an architecture at 2 a.m.
The skills are open source: github.com/viswanathanb/vibe-app-skill. In Claude Code they install as a plugin:
# add the marketplace once, then install the plugin
claude plugin marketplace add viswanathanb/vibe-app-skill
claude plugin install vibe-app-skill@vibe-app-skill
# later: pick up new skill versions
claude plugin update vibe-app-skill@vibe-app-skill
# in a new, empty project directory
claude
> /vibe-app-skill:crud-app-scaffoldThe README also covers installing the skills as plain folders for other coding agents, what each crud-* skill owns, and how to maintain them. The app that came out of this post is at github.com/viswanathanb/talos-manager.
me ──prompt──▶ Claude Code
│ reads
▼
┌────────────────┐
│ crud-* skills │ conventions + scripts + golden example
└───────┬────────┘
│ runs
▼
new-app.sh ──▶ backend/ + frontend/ + Taskfile + Dockerfile + render.yaml
│
add-resource.sh ──▶ one package + one feature folder per resource
│
task check ──▶ lint, test, build, osv1. The Prompt
/vibe-app-skill:crud-app-scaffold
Create "Talos Manager", Go module github.com/viswanathanb/talos-manager.
It manages Talos Linux Kubernetes clusters.
Resources:
- Cluster (owned + shareable): name, endpoint URL, Kubernetes version,
Talos version, status (provisioning|ready|degraded|deleting), description.
- Machine (child of Cluster): hostname, IP address, role (controlplane|worker),
status (pending|ready|error).
Members can create clusters and share them with teams; viewers only see
what's shared with them.
Start with the spec and confirm it with me before generating code.That last line matters. The skill's first step is to write an APP_SPEC.md and stop. The agent filled in everything I hadn't said and listed its assumptions back to me:
- cluster name unique per owner, endpoint must be an
https://URL, status defaults toprovisioning - hostname and IP unique within a cluster, IP validated as IPv4 or IPv6, role defaults to
worker - machines live as a table inside the cluster detail page, no standalone page
- anyone with
editoron a cluster can add or edit its machines; viewers see them read-only - deleting a cluster cascades to its machines
Each of those is a decision I would otherwise have discovered as a bug three weeks later. Reading a one-page spec and saying "yes" took two minutes. Spec first is the cheapest review you will ever do.
2. The Skeleton
One script call. It copied the root files, generated the Go module, ran bun create vite, installed shadcn, copied the skeleton UI, and verified gofmt, vet, build, tests, prettier, eslint, tsc and the production bundle. Output:
talos-manager/
AGENTS.md APP_SPEC.md Taskfile.yml docker-compose.yml
Dockerfile render.yaml .env.example .gitignore
backend/ Go API: auth, users, RBAC + ReBAC, teams
frontend/ React SPA: login/signup, teams + members, admin usersBefore I had written a single line about clusters, I had login, sessions, an admin page, teams, and a generic sharing API. That's the part of every internal tool that eats the first two weeks.
3. Two Resources, Two Access Patterns
This is where the skills earned their keep. The authz skill defines three patterns and makes you pick one per resource:
| Pattern | Who can see it | Example |
|---|---|---|
| owned | creator is owner, shares with users or teams as editor/viewer | Cluster |
| child of parent | inherits from the parent via a parent tuple | Machine |
| admin-managed catalog | admins edit, everyone signed in reads | reference data |
Under the hood it's a small Zanzibar: relation tuples in Postgres (cluster:1#owner@user:7, cluster:1#viewer@team:3#member, machine:9#parent@cluster:1) and a schema that says which relations grant which permissions.
"cluster": {
Relations: {"owner": {"user"}, "editor": {"user", "team#member"}, "viewer": {"user", "team#member"}},
Permissions: {"view": {"viewer", "edit"}, "edit": {"editor", "manage"}, "delete": {"owner"}, "manage": {"owner"}},
},
"machine": {
Relations: {"parent": {"cluster"}},
Permissions: {"view": {"parent->view"}, "edit": {"parent->edit"}, "delete": {"parent->edit"}, "manage": {"parent->manage"}},
},Machines have no relations of their own. Every permission check walks up to the cluster. Share a cluster with a team, and the team sees every machine in it. Revoke it, and they're gone. No per-machine ACLs to keep in sync.
Cluster came out of the add-resource.sh script in one shot, then the agent swapped the placeholder fields for the real ones: a composite unique index on (created_by_id, name), a service-level check that the endpoint parses as https, an allow-list of sortable columns, and a React form whose zod schema mirrors the Go binding tags.
Machine was hand-written from the same template because the child pattern wants different plumbing: routes nested under the parent (POST /api/clusters/:id/machines), Authorize("edit", cluster) instead of an RBAC create action, a parent tuple granted in the same transaction as the row, and a ClusterID that is simply absent from the update input so it can't be changed.
One wrinkle the agent solved on its own: the cluster's delete needs to cascade to machines, but machine imports cluster for the parent type. It added a tiny interface on the cluster side, implemented it on the machine service, and wired them together in server.go. Textbook dependency inversion, unprompted, and it even left a compile-time assertion.
4. "A Few Eternities Later": Testing
task check passed. That proves the code compiles and the linters are happy. It proves nothing about whether Bob can read Alice's cluster.
The agent wrote a curl script with four cookie jars (admin, two members, one viewer) and ran it against a real Postgres. Fifty assertions:
# isolation
ok bob list has only his own (200) bob total=1
ok bob detail -> 403
ok bob machines -> 403
ok bob patch machine -> 403
# share with bob as viewer
ok bob now sees it in list bob total=2
ok bob detail ok bob perms=['view']
ok bob still cannot edit cluster (403)
# share with a team (carol is a member) as editor
ok carol sees cluster perms=['edit', 'view']
ok carol can add machine via team editor (201)
ok carol cannot delete cluster (403)
ok alice removes team from cluster (204)
ok carol lost access (403)
# cascade delete
ok alice deletes cluster (204)
ok machine gone (admin -> 404)followed by a select object_type, count(*) from relation_tuples to confirm nothing dangled.
Things that went wrong, in order:
- Port 5432 was taken by a Postgres already on my laptop. The agent moved the compose port to 5433 in
.envand moved on. - The first test run reported 29 failures. All of them were a quoting bug in the agent's own test helper. It noticed, fixed the helper, reset the database, and re-ran. 44 passed.
- Three failures survived. Sharing with a team returned 400. Running the same request by hand returned 204. The culprit was macOS shipping bash 3.2, which treats a
#inside"$(...)"as a comment and silently truncatedteam:1#member"}out of the JSON body. The API was fine; the test harness was from 2007. - I don't have the Chrome extension, so there was no browser for the agent to drive. It found Google Chrome on disk, launched it headless with a remote debugging port, and wrote an 80-line Bun script speaking the DevTools protocol: log in, click through, fill forms with React-compatible setters, take screenshots. It then read the screenshots to confirm the machine table, the 409 toast and the share dialog rendered.
- One real UI bug fell out of that: the IP field said "Invalid IPv4 address" for a bad IPv6 because zod's union only reports its first branch. Fixed, rebuilt, re-tested.
please deploy to local and test it
turned into: build the production Docker image, run it in production mode against the local database with a bootstrap admin, re-run the fifty API checks against the container, re-run the browser walkthrough. All green.
5. Deploy to Render: Where the Vibe Stops
lets deploy to render
This is the part that is not automatic, and I think it's right that it isn't. The agent did everything it could without my credentials:
- reviewed
render.yaml(one Docker web service, one Postgres, same region,JWT_SECRETgenerated by Render, bootstrap admin assync: false, signup off) - checked the Dockerfile's Go image matched
go.mod - confirmed
.envwas ignored and no secret literals were in the tree - pushed
mainto GitHub
Then it stopped and handed me a short list:
1. Log the Render CLI in: ! render login
2. Create the Blueprint in the browser:
https://render.com/deploy?repo=https://github.com/viswanathanb/talos-manager
Render will prompt for BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD.
3. Tell me when it's applied, ideally with the srv-... ID from the dashboard URL.Three clicks and one password later I pasted the service ID back. It stored the ID in .env for task deploy, then probed the live URL:
healthz {"status":"ok"} [200]
login page 200 text/html
deep link /clusters/1 200 text/html (SPA fallback works)
unauthenticated /api/clusters 401
POST without X-Requested-With 403 missing X-Requested-With header
signup 403 signup is disabled
security headers HSTS, CSP, X-Frame-Options DENY, nosniffAuto-deploy is on, so every push to main rebuilds the image and rolls it out.
What I Actually Did
| Me | The agent |
|---|---|
| Typed four prompts | Wrote ~120 files across Go and TypeScript |
| Read a one-page spec and said yes | Listed its assumptions before writing code |
| Watched | Ran lint, tests, build, osv-scanner after every change |
| Watched | Wrote and ran 50 API assertions with four accounts |
| Watched | Found Chrome, drove it headless, read its own screenshots |
Ran render login, clicked Apply, typed a password | Reviewed the Blueprint, pushed, verified the live URL |
| Committed to GitHub | Pointed out my PAT was embedded in the git remote URL |
Honest caveats:
- The skill is the product. Without it the same prompt would have produced a plausible-looking app with a different auth story each time. The constraints are what made the output boring, and boring is the goal for a CRUD app.
- Dependencies aren't free. osv-scanner flagged two advisories with no fixed version yet, one in
golang.org/x/cryptoand one transitive npm package. The agent reported them and did not add ignores. That is the correct behaviour and also a reminder that a generated app is still an app you own. - Free tier is a demo tier. The Render service sleeps when idle and the free Postgres expires. The agent said so, twice.
- I still own the decisions. Who can edit machines, whether a viewer-role user granted editor on a cluster may add machines (yes), whether cluster delete cascades (yes). The agent proposed; I approved. The spec file records all of it.
Summary: The Flow
- Install a skill that encodes your conventions, golden example and scripts (vibe-app-skill, see its README)
- One prompt: name, module, resources, access patterns
- Agent writes the spec, you say yes
- Script generates the skeleton: auth, users, teams, sharing, quality gates
- One resource at a time, parents first,
task checkafter each - Two-account tests against a real database, then a real browser
- Production image locally, same tests again
- Push; the human does the three steps that need credentials
- Agent verifies the live URL
From an empty directory to https://talos-manager-p9jd.onrender.com in one sitting, with the only hand-written artefacts being four prompts and a password. The spreadsheet is retired.