Tech Stack
Go JSON API + React SPA served from a single binary and deployed as one container. Public pages are prerendered to static HTML at build time so search engines and AI agents can read them.
Architecture
Backend: Go stdlib net/http router, pgx/v5 for Postgres, sqlc for query generation, slog for logging, embedded SQL migrations via go:embed.
Frontend: React + TypeScript, shadcn/ui components, Tailwind CSS, in React Router framework mode with ssr: false — a pure SPA at runtime (no Node server in production), with Vite as the underlying dev server and bundler. Public routes are listed in the prerender config so they ship as real HTML for search engines and AI agents (see Public pages: prerender for SEO and AI agents). ALWAYS use the frontend-design skill when writing or modifying frontend code — it provides the design direction for layouts, styling, component aesthetics, and UI polish.
Project Layout
.
├── backend/
│ ├── cmd/server/main.go # Entrypoint
│ ├── internal/
│ │ ├── config/ # Env var parsing
│ │ ├── database/
│ │ │ ├── migrations/*.sql # Embedded, forward-only
│ │ │ ├── queries/*.sql # sqlc source
│ │ │ └── sqlc/ # Generated — do not edit
│ │ └── handler/ # HTTP handlers (JSON API)
│ ├── go.mod
│ ├── go.sum
│ └── sqlc.yaml
├── e2e/ # Visual checks (Playwright screenshots)
│ └── package.json
├── frontend/ # React SPA (React Router framework mode)
│ ├── app/
│ │ ├── routes.ts # Route declarations
│ │ ├── root.tsx # Root layout (head, favicon, global providers)
│ │ ├── routes/ # Route modules
│ │ └── components/ui/ # shadcn/ui (generated, editable)
│ ├── react-router.config.ts # ssr: false (+ prerender list of public routes)
│ └── build/ # Build output (gitignored); static assets in build/client/
└── Dockerfile
Ensure the project .gitignore includes at least:
backend/server # add the actual output path used by `go build`
frontend/build/
frontend/.react-router/ # generated route types
frontend/node_modules/
e2e/node_modules/
.venv/
.env
backend/cmd/server/server is the locally compiled Go binary produced by go build. It must not be committed.
.env holds all service connection strings and application secrets needed to run locally. It must never be committed. After creating or updating it, restrict permissions:
chmod 0600 .env
Example contents:
DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
BASE_URL="http://localhost:5173"
REDIS_URL="redis://localhost:6379"
N8N_WEBHOOK_URL="http://localhost:5678/webhook"
Always quote values with double quotes — some values (e.g., SMTP app passwords) contain spaces.
The Go backend reads these values via os.Getenv(). During local development, load the file before starting the server using . .env (POSIX dot syntax — not source, which is a bash builtin and may not work in all shells):
set -a && . .env && set +a
This bridges the gap between local and deployed: locally .env provides the values; deployed, Kamal config provides them. The Go code stays the same (os.Getenv("REDIS_URL")).
Entering secrets in the local .env file
Never ask for secret values through the chat.
Agent-generated secrets (JWT keys, session secrets, HMAC keys, etc.) — generate and write directly:
mise x -- python -c "import secrets; print(secrets.token_urlsafe(32))"
User-provided secrets (OAuth credentials, SMTP passwords, API keys, etc.):
Write
.envwithREPLACE_WITH_placeholders for user secrets. For example:DATABASE_URL="postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" JWT_SECRET="<already generated by agent>" GOOGLE_CLIENT_ID="REPLACE_WITH_GOOGLE_CLIENT_ID" GOOGLE_CLIENT_SECRET="REPLACE_WITH_GOOGLE_CLIENT_SECRET" SMTP_PASSWORD="REPLACE_WITH_SMTP_APP_PASSWORD"Open
.envin the user's editor using the full absolute path. Do not offer pasting values in the chat as an alternative. Pick the right command for the user's platform — detect WSL on Linux via:{ grep -qi microsoft /proc/version 2>/dev/null \ || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; } \ && echo WSLPlatform Command (the agent runs it directly) macOS open -a TextEdit /Users/name/project/.envWindows (native, git bash) start notepad "C:\Users\name\project\.env"WSL on Windows notepad.exe /home/name/project/.env(WSL maps the path automatically)Linux (native) Ask the user to run nano <ABSOLUTE_PATH>/.envin a separate terminal.Walk them through obtaining each credential (use reference docs where applicable:
references/google-auth.md,references/smtp-gateway.md), and expressly tell them not to paste values into the chat. Ask them to save and confirm.Validate: read
.env, checkREPLACE_WITH_prefixes are gone. Never output actual secret values.
mise.toml should not be gitignored — it is committed to the repo so all developers use the same tool versions.
Project Documentation
Maintain these artifacts in the project's docs/ directory:
docs/PRD.md — Product Requirements Document
Describes WHAT the web app delivers, not how it works. Must be independent from technical implementation.
Sections: Overview, Target Users, Core Features, User Flows, Non-Functional Requirements, Out of Scope.
When gathering requirements:
- Help the user fill in blanks when requirements are vague, incomplete, ambiguous, or contradictory.
- Suggest ideas the user may not have thought of that make sense given the web app's context.
- Keep the PRD always up-to-date as requirements evolve.
docs/TASKS.md — Development Task Tracker
Generated from the PRD, reflecting development phases. Tasks must account for the technical context of the available skills.
Format per task: Task name | Status (Pending / In Progress / Done / Blocked) | Reason/Notes
Keep up-to-date as work progresses.
docs/adr/NNN-topic.md — Architecture Decision Records
Numbered markdown files for each technical decision.
Template:
# NNN - Title
**Status:** Accepted | Rejected | Superseded by [ADR-NNN]
## Context
[What situation or requirement prompted this decision]
## Decision
[What was decided]
## Rationale
[Why this choice was made]
## Trade-offs
**Pros:**
- ...
**Cons:**
- ...
## Alternatives Considered
- [Alternative 1]: [Why discarded]
- [Alternative 2]: [Why discarded]
Focus on why a choice was made, what was considered, and what was discarded. No need for deep implementation details — the code itself is the documentation.
docs/INFRASTRUCTURE.md — Service Inventory
Lists every service the app depends on beyond the Go binary. Created when the first accessory is introduced; may be empty for apps with no external services.
Format:
| Name | Image | Local Port | Env Var | Type |
|---|---|---|---|---|
| db | supabase/postgres:17.6.1.111 | 5432 | DATABASE_URL | backend |
| redis | redis:7-alpine | 6379 | REDIS_URL | backend |
| n8n | n8nio/n8n:latest | 5678 | — | standalone |
Type: backend = consumed by web/workers via env var. standalone = accessed directly by user in browser (no Go integration).
Key Decisions
Single-binary serving
The Go server handles everything: API routes under /api/, frontend static files, and SPA routing. In development, Vite's dev server proxies API calls to the Go backend.
In local development, the Vite dev server is the user-facing entry point — all browser traffic goes through it, and the proxy forwards /api/* and /auth/* to Go transparently. This means external-facing URLs — including OAuth redirect URIs configured in third-party consoles (e.g., Google Cloud) — must use the Vite port, not the Go backend port. Set BASE_URL in .env to the Vite dev server origin (e.g., http://localhost:5173). In deployed environments, BASE_URL is set to the app's public URL (e.g., https://myapp.example.com) via the deploy config — see the app-deploy skill. The Go backend reads BASE_URL from the environment in all cases — no Host header inference needed.
The SPA catch-all must not serve the SPA shell for every non-API path. Doing so returns HTML with text/html content type for .js, .css, and other hashed assets under /assets/, causing browsers to reject them with MIME type errors — the app will appear completely broken in production even though it works in development (where Vite's dev server handles assets directly). Always use the pattern below — it encodes three rules learned the hard way: real files go through http.FileServer (correct Content-Type); prerendered pages (about/index.html) are served directly, because handing the directory to FileServer 301-redirects every sitemap URL to its trailing-slash form; and directories are never given to FileServer, which would expose a browsable listing of the app's internals. http.Dir restricts access to the specified directory, so this is safe against path traversal.
The SPA shell filename depends on the prerender config (see Public pages: prerender for SEO and AI agents): builds without a prerender list emit the shell as index.html; builds that prerender / (any app with public pages) emit the prerendered home as index.html and the shell as __spa-fallback.html. The pattern below detects this once at startup — and / must always be served from index.html (when / is prerendered, that file is the home page, not the shell).
frontendDistmust be the literal string"frontend/dist". Never use"../frontend/dist", never use an absolute path like"/frontend/dist", and never add fallback logic that tries multiple paths. The binary's working directory in production is the Dockerfile'sWORKDIR— notbackend/. The path../frontend/distseems correct when looking at the local repo layout (Go runs frombackend/), but in the Docker container it escapes to the wrong parent and causes a 404. This mistake is invisible during local development because Vite serves the frontend and the static-file handler is not registered whenDEV_MODEis set — the error only surfaces after deploy. The Dockerfile section below shows the matching layout.
frontendDist := "frontend/dist"
if _, err := os.Stat(frontendDist); err == nil && !cfg.DevMode {
// SPA shell: __spa-fallback.html when the build prerenders "/", index.html otherwise
spaShell := "index.html"
if _, err := os.Stat(filepath.Join(frontendDist, "__spa-fallback.html")); err == nil {
spaShell = "__spa-fallback.html"
}
fs := http.FileServer(http.Dir(frontendDist))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/auth/") {
http.NotFound(w, r)
return
}
// "/" always maps to index.html (the prerendered home page in prerendered builds)
if r.URL.Path == "/" {
fs.ServeHTTP(w, r)
return
}
target := filepath.Join(frontendDist, filepath.Clean(r.URL.Path))
if info, err := os.Stat(target); err == nil {
if !info.IsDir() {
fs.ServeHTTP(w, r) // real asset; FileServer sets Content-Type
return
}
// Prerendered page ("/about" → about/index.html). Serve the file directly:
// delegating a directory to FileServer would 301 to "/about/", adding a
// redirect hop to every URL listed in sitemap.xml.
page := filepath.Join(target, "index.html")
if _, err := os.Stat(page); err == nil {
http.ServeFile(w, r, page)
return
}
// Directory without index.html (e.g. /assets/): fall through to the shell —
// FileServer would render a browsable listing of the app's internals.
}
// Unmatched route → SPA shell
http.ServeFile(w, r, filepath.Join(frontendDist, spaShell))
})
} else if !cfg.DevMode {
slog.Warn("frontend dist not found — SPA routes will return 404", "path", frontendDist)
}
Public pages: prerender for SEO and AI agents
A client-rendered SPA is effectively invisible to most non-browser readers. Googlebot does execute JavaScript, but in a delayed second indexing wave with a crawl-budget penalty; Bing is inconsistent; and AI crawlers (GPTBot, ClaudeBot, PerplexityBot, CCBot) and link unfurlers (WhatsApp, Slack, LinkedIn) fetch raw HTML and run no JavaScript at all — they see an empty <div id="root">. This only matters for public pages: the authenticated app gains nothing from server rendering.
Every app is already scaffolded in framework mode (see Frontend scaffold and cleanup), so this is a single config knob — list every public route (landing, pricing, about, blog, docs, public listings) in prerender:
// frontend/react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: false, // pure SPA at runtime — no Node server, ever
prerender: ["/", "/about", "/pricing"], // every public route
} satisfies Config;
Apps fully behind login (internal tools, dashboards) simply omit prerender — nothing else about them changes.
At build time React Router renders each listed route to static HTML inside the client build: index.html (the prerendered home — always include / in the list), about/index.html, and so on, plus a small .data file per route used for client-side navigations. Because / is prerendered, the SPA shell is emitted as __spa-fallback.html — the Go catch-all above detects it automatically. Browsers hydrate the prerendered HTML into the normal SPA, so client-side navigation and the app experience are untouched; bots and agents get full HTML.
Never introduce an SSR framework (Next.js, Remix) or a Node server runtime for SEO. Prerendering is build-time only and preserves the single-Go-binary deployment.
Constraint of ssr: false: server loader/action functions are only allowed on prerendered routes (they run at build time, e.g. reading local markdown for a blog). All other routes must use clientLoader/clientAction or plain fetch to the Go API, as usual.
Per-route metadata
Every public route module must export meta with a real title, description, and Open Graph tags — this is what makes search snippets and link previews work (something a plain SPA cannot deliver at all):
export function meta() {
return [
{ title: "Acme — Effortless invoicing" },
{ name: "description", content: "Send invoices in seconds, get paid faster." },
{ property: "og:title", content: "Acme — Effortless invoicing" },
{ property: "og:description", content: "Send invoices in seconds, get paid faster." },
{ property: "og:image", content: "https://acme.example.com/og.png" },
];
}
Dynamic public content
Build-time prerendering only covers paths known at build time. prerender also accepts an async function, so content that lives in the repo (e.g. blog posts as markdown files) can be enumerated at build. For public pages created at runtime (public listings, profiles, shared documents), choose by weight:
- Lightweight (default): keep the content client-rendered, but have the Go handler for those routes inject the correct
<title>, meta description, OG tags, and JSON-LD into the SPA shell before serving it. Indexing and unfurls get the essentials without any rendering infrastructure. - Full: render those routes server-side from Go with
html/template, reusing the built Tailwind CSS (add the template files to Tailwind's source globs so their classes are included). Reserve this for apps where public content pages are the product (marketplaces, directories, job boards).
Bot hygiene checklist
For every app with public pages, serve robots.txt, sitemap.xml, and llms.txt from Go handlers, deriving every URL from BASE_URL. Never write them as static files: the public origin doesn't exist until deploy, so a static file inevitably carries a made-up domain that silently breaks crawler discovery in production. Cover the derivation with a test that runs against two synthetic origins (.test domains) — with a single origin, a hardcoded URL passes by coincidence.
robots.txt— must not disallow AI crawlers (GPTBot, ClaudeBot, PerplexityBot, CCBot); end withSitemap: <BASE_URL>/sitemap.xml.sitemap.xml— every public URL.llms.txt— plain-markdown map of the public content, for LLM agents.- JSON-LD structured data (this one lives in the route's HTML, not in Go) on pages that map to schema.org types (Product, Article, FAQPage, Organization).
Postgres first
PostgreSQL is the primary external service. Use Postgres-backed alternatives whenever possible:
- Queues:
pgmq— lightweight message queue with visibility timeout, archive, and batch operations. See references/pgmq.md - Pub/Sub:
LISTEN/NOTIFY— no extension needed; combine with a persistence layer for durability. See references/notify-patterns.md - Caching: unlogged tables
- Scheduling:
pg_cron+pg_net— in-database cron plus async HTTP requests for triggering app endpoints on a schedule. Preferred over container-level cron. See references/pg-cron.md - Search:
pgroonga— full-text search for all languages including CJK, with boolean queries, ranking, highlighting, and JSONB search. No configuration needed. When the app involves searchable content (products, articles, listings, messages, logs), proactively propose adding search. Falls back to nativetsvector/tsqueryonly when a simpler built-in solution suffices for a single well-supported language. See references/pgroonga.md - Vectors:
pgvector— embeddings storage and similarity search with HNSW and IVFFlat indexes. See references/pgvector.md - JSON validation:
pg_jsonschema— validatejson/jsonbcolumns against JSON Schema via CHECK constraints. See references/pg-jsonschema.md - Geospatial:
postgis— geometry types, spatial indexes, and geographic functions. See https://postgis.net/ - HTTP from SQL:
pg_net— asynchronous HTTP/HTTPS requests from SQL; used withpg_cronfor scheduled calls or from triggers for webhooks - Other notable extensions:
pgjwt,pg_stat_statements,pgaudit,pg_hashids
All 60+ bundled extensions from supabase/postgres are available.
When the application needs a capability that Postgres and its extensions cannot provide — a pre-built tool like n8n, a specialized system like Kafka, or a use case where a dedicated service is clearly superior — add it as an accessory. See Local Services for running it locally and docs/INFRASTRUCTURE.md for recording it. At deployment time, each accessory gets its own VM (see the app-deploy skill).
sqlc for all queries
All SQL lives in backend/internal/database/queries/*.sql. Run cd "$(git rev-parse --show-toplevel)/backend" && mise x -- sqlc generate after changes. Never write raw SQL strings in Go handler code.
Always include emit_json_tags: true in sqlc.yaml so that generated Go structs include lowercase JSON tags (e.g., json:"id" instead of exporting ID as-is). Without this, the API returns PascalCase field names that don't match frontend expectations.
Migrations at startup
Embedded SQL files applied in order before the server accepts traffic. Forward-only, numbered sequentially (001_create_users.sql, 002_add_tasks.sql, …). Each migration should be idempotent where possible (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS).
The go:embed directive only accepts files in the same directory or subdirectories of the file that declares it — paths with .. are rejected by the compiler. Place the embed directive in a Go file next to the migrations/ directory (e.g., backend/internal/database/migrate.go), not in cmd/server/main.go.
Database connection retry
The Go backend should retry the database connection at startup (up to 6 attempts with exponential backoff: 1 s, 2 s, 4 s, 8 s, 16 s, 32 s — ~63 s total). This handles parallel startup — Preview starts all servers simultaneously, so the backend may come up before the database is ready — and is also good practice for production deployments.
var pool *pgxpool.Pool
for i := range 6 {
pool, err = pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err == nil {
if err = pool.Ping(ctx); err == nil {
break
}
pool.Close()
}
delay := time.Second * (1 << i) // 1s, 2s, 4s, 8s, 16s, 32s
slog.Warn("database not ready, retrying", "attempt", i+1, "delay", delay, "err", err)
time.Sleep(delay)
}
if err != nil {
slog.Error("failed to connect to database", "err", err)
os.Exit(1)
}
Real-time updates via SSE
The Go backend listens for Postgres NOTIFY events and holds open a standard HTTP response with Content-Type: text/event-stream for each connected client. The React frontend uses the browser's built-in EventSource API. SSE is preferred over WebSockets to avoid adverse proxy configurations.
Frontend scaffold and cleanup
Scaffold the frontend non-interactively with:
bash -c 'cd "$(git rev-parse --show-toplevel)" && mise x -- npx create-react-router@latest frontend --yes --no-git-init --no-install --no-agent-skills'
The template ships with Tailwind CSS preconfigured and these npm scripts: dev (react-router dev — Vite under the hood), build (react-router build), and typecheck (react-router typegen && tsc). Routes are declared in app/routes.ts:
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("pricing", "routes/pricing.tsx"),
] satisfies RouteConfig;
Then apply this cleanup before writing application code:
Set
ssr: falseinreact-router.config.ts— the template defaults tossr: true, which requires a Node server; this stack never runs oneAdd the dev proxy in
vite.config.tsso the dev server forwards backend routes to Go (keep the existingpluginsandresolveentries). Targets must match the GoPORTfrom.env:server: { proxy: { "/api": "http://localhost:8080", "/auth": "http://localhost:8080", // Served by Go, derived from BASE_URL — without the proxy they 404 only in dev "/robots.txt": "http://localhost:8080", "/sitemap.xml": "http://localhost:8080", "/llms.txt": "http://localhost:8080", }, },Delete
frontend/Dockerfile,frontend/.dockerignore, andfrontend/README.md— the project root owns the Dockerfile (see Dockerfile), and the template README describes an SSR setup this stack doesn't useDelete
app/welcome/and replace the default home route (app/routes/home.tsx) with the app's real oneRemove the
startscript and@react-router/serve— SPA mode never runs a Node server. Keep@react-router/nodeandisbot: build-time prerendering still needs them to resolve a server runtime; removing them breaksreact-router buildandtypegenDelete
public/favicon.icoand the template's Google Fonts (Inter) links inapp/root.tsx— the frontend-design skill handles favicon and typographySet a real title and description via the
metaexport (root or home route) — never ship the template defaults
Do not create a placeholder public/favicon.svg — the frontend-design skill handles favicon generation separately.
Deployment Constraints
These match the app-deploy skill requirements:
- Always source port from
PORTenv var (set to80for deployed app,8080for local dev) - Health check:
GET /up→ HTTP 200 - Database via
DATABASE_URL(preferred) or individualPOSTGRES_*env vars — fail hard if missing - File storage at
BLOB_STORAGE_PATH(e.g./data/blobs) - PostgreSQL as the primary data store (with 60+ bundled extensions via
supabase/postgres). If the application needs services beyond what Postgres provides, additional accessories can be added via the deploy skill's Kamal layer - No ORMs, no JavaScript frameworks beyond React, no CSS preprocessors
Dockerfile
Multi-stage: (1) build frontend with Node, (2) build Go binary, (3) minimal Alpine runtime with binary + frontend assets (copied to frontend/dist/) + CA certs. The Go binary embeds migrations; frontend assets are copied alongside the binary.
The FROM tags in the Dockerfile use the same major versions as mise.toml with the -alpine suffix — e.g., FROM node:24-alpine and FROM golang:1-alpine. Docker Hub resolves these floating tags to the latest minor/patch at build time, so the Dockerfile stays in sync with mise.toml without manual version lookups. When a new Go or Node minor/patch is released, the next build picks it up automatically. The GHA BuildKit cache detects the manifest change and rebuilds from that layer down.
The runtime stage must place the binary and the frontend client build (copied to frontend/dist) as siblings under WORKDIR:
# Stage 1: Build frontend
FROM node:24-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Build backend
FROM golang:1-alpine AS backend-build
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
# Stage 3: Runtime — binary and frontend assets as siblings under WORKDIR
FROM alpine:3
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=backend-build /server ./server
COPY --from=frontend-build /app/frontend/build/client ./frontend/dist
EXPOSE 80
ENV PORT=80
CMD ["./server"]
The specific WORKDIR path does not matter — what matters is that both the binary and the frontend assets are placed directly under it. The frontend build outputs to frontend/build/client/; the Dockerfile copies that directory to frontend/dist, the literal path the Go binary expects (see the SPA catch-all above). Since CMD runs from WORKDIR, copying both into WORKDIR satisfies this. In local dev, Vite serves the frontend, so the Go handler for static files is only registered when DEV_MODE is not set.
Do not create a .dockerignore file. The multi-stage build already keeps the final image small, and a .dockerignore that accidentally excludes files needed by go:embed (e.g., migrations/) will break the build with no clear error at authoring time.
Local Development
All tools are invoked via mise (set up by computer-setup) using the mise x command, which reads mise.toml and runs the tool at the pinned version without requiring shell activation. The database runs as a supabase/postgres container via podman (also set up by computer-setup), matching the production image.
Container naming convention: Each project's database container is named
<repo_name>-db(e.g.,myapp-db), where<repo_name>is the basename of the project's root directory. This prevents collisions when multiple cofounder projects coexist on the same machine. Derive the name once at the start of the session and use it consistently for allpodmancommands.
Critical:
go.modlives inbackend/, not in the project root. All Go and sqlc commands (mise x -- go run,mise x -- go build,mise x -- go test,mise x -- go mod tidy,mise x -- sqlc generate) must execute from thebackend/directory. Always includecd "$(git rev-parse --show-toplevel)/backend" &&inside thebash -cstring. When a command chain involves multiple layers of shell invocation (bash → go), prefer writing a small helper script instead of nesting everything in a singlebash -cstring — this avoids the most common source of repeated build failures.
Project tool versions
On first setup (when mise.toml does not yet exist in the project root), create it manually:
[tools]
go = "1"
sqlc = "1"
python = "3.14"
node = "24"
jq = "1"
[settings]
python.compile = false
python.compile = false is required — it pins Python to the newest precompiled patch instead of compiling from source (which fails without build deps).
Then trust and install the tools:
mise trust
mise install
This mise.toml is committed to the repo, ensuring all developers use the same versions. mise.toml specifies major versions only (e.g., go = "1"); mise resolves these to the latest stable minor/patch at install time. To change a major version, edit mise.toml and re-run mise install.
All tool invocations in this skill use the mise x command (e.g., mise x -- go run ./cmd/server). This runs the tool at the version specified in mise.toml without requiring shell activation hooks — it works reliably in Claude Code's non-interactive shell, in Preview's launch.json, and in any script context.
Upgrade tools
Run on every session (this is not part of service startup — it is a standalone step that must execute every time this skill is loaded).
Ensure
mise.tomlhas the[settings]section above; add it if missing.Update mise itself:
# macOS: brew upgrade mise # Linux, WSL, Windows: mise self-update -yUpgrade the project tools:
mise upgrade
Use mise upgrade, not mise install — install skips already-installed versions; upgrade checks for newer patches.
1. Start the database and local services
The command below uses the default Postgres port 5432. If that port is already in use on the host (by another project's container, or another Postgres instance) and the container fails to start, pick any free port, update DATABASE_URL in .env to use it, and change the -p flag to match it. The env file is the source of truth.
# Derive the container name from the repo directory
CONTAINER_NAME="$(basename "$(pwd)")-db"
# Start supabase/postgres container (matching production image)
# Important: provide only the POSTGRES_PASSWORD environment variable. The database is started with both user and database name preset to `postgres`.
# Match the port in DATABASE_URL in .env.
podman run -d \
--name "$CONTAINER_NAME" \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
supabase/postgres:17.6.1.160
# Verify it's ready (uses container exec instead of pg_isready)
podman exec "$CONTAINER_NAME" pg_isready -U postgres
Local Services
When the application needs services beyond Postgres, run them as podman containers locally. Each service maps to an accessory that will be provisioned as a dedicated VM in deployment.
Naming convention
Every project container is named <repo_name>-<accessory_name>:
myapp-db
myapp-redis
myapp-n8n
The -db convention already exists for Postgres. Extend it to all accessories. This enables the cleanup pattern (see Stopping all project containers).
Start-or-create pattern
Containers persist across sessions. On a fresh session the containers from the previous session may already exist (stopped). Always use the start-or-create pattern instead of a bare podman run:
podman start myapp-redis 2>/dev/null || \
podman run -d --name myapp-redis -p 6379:6379 redis:7-alpine
podman start succeeds silently if the container exists (running or stopped). If it doesn't exist, the fallback podman run creates it. This prevents "name already in use" errors on session resume.
Two accessory types
Backend-connected (Redis, Kafka, etc.): start-or-create → readiness check → add env var to .env → add Go client → record in docs/INFRASTRUCTURE.md.
Standalone (n8n, WordPress, etc.): start-or-create → readiness check → give user localhost:<port> link → record in docs/INFRASTRUCTURE.md. No Go code unless the backend also calls its API.
Hybrid (e.g., n8n with webhook): treat as standalone (browser link) AND add backend env var (e.g., N8N_WEBHOOK_URL). Type stays standalone; the env var column signals the backend dependency.
Port conflict detection
Before starting a container, check podman ps -a --format '{{.Names}} {{.Ports}}' | grep '<port>'. If the default port is already in use, pick any free port, update the URL env var in .env (e.g., REDIS_URL) to use it, and expose that port in the container. The .env file is the source of truth.
Common recipes
| Accessory | Image | Port | Readiness check |
|---|---|---|---|
| Redis | redis:7-alpine |
6379 | podman exec <name> redis-cli ping |
| n8n | n8nio/n8n:latest |
5678 | curl -s http://localhost:5678/healthz |
| Meilisearch | getmeili/meilisearch:latest |
7700 | curl -s http://localhost:7700/health |
| WordPress | wordpress:latest |
8080 | curl -s http://localhost:8080 |
| WAHA | devlikeapro/waha:latest |
3000 | curl -s http://localhost:3000/api/health |
These are starting points. The agent should check the image's documentation for the correct ports and readiness endpoints.
Local vs. deployed hostnames
| Service | Local (.env) |
Deployed hostname |
|---|---|---|
| Postgres | localhost:5432 |
db:5432 |
| Redis | localhost:6379 |
redis:6379 |
| n8n | localhost:5678 |
n8n:5678 |
Locally, all services are on localhost. Deployed, each accessory gets its own VM — hostname matches the accessory name via CloudStack internal DNS. Never use public IPs for inter-service communication. .env (local) and Kamal config (deployed) provide the values; Go reads them identically via os.Getenv().
2. Start the Go API (terminal 1)
bash -c 'ROOT="$(git rev-parse --show-toplevel)" && set -a && . "$ROOT/.env" && set +a && cd "$ROOT/backend" && DEV_MODE=1 mise x -- go run ./cmd/server'
3. Start the frontend dev server (terminal 2)
bash -c 'cd "$(git rev-parse --show-toplevel)/frontend" && mise x -- npm install && mise x -- npm run dev'
Don't assume the default Vite port (5173) — Vite automatically picks the next available port when the default is already in use by another project. After starting the dev server in the background, read the task output and look for the Local: line in Vite's startup banner (e.g., Local: http://localhost:5174/). Use the URL from that line — not a hardcoded port — for all subsequent access, and keep BASE_URL in .env (plus any OAuth redirect URIs registered on it) in sync with the actual port.
If the task output is no longer available, detect the port from the OS:
lsof -i -P -n -sTCP:LISTEN | grep node | awk '{print $9}'
npm run dev runs react-router dev — Vite under the hood — and proxies /api/*, /auth/*, and the SEO files to the Go backend via the server.proxy block added at scaffold time.
Stopping all project containers
REPO_NAME="$(basename "$(pwd)")"
# Stop and remove all containers for this project
podman ps -a --filter "name=^${REPO_NAME}-" --format '{{.Names}}' | xargs -r podman stop
podman ps -a --filter "name=^${REPO_NAME}-" --format '{{.Names}}' | xargs -r podman rm
This relies on the naming convention (<repo_name>-<accessory_name>) and removes all project containers at once.
Visual Check (Playwright screenshots)
Do NOT use Claude Desktop Preview servers. Start Go backend and Vite dev server manually (steps 2–3 above).
After tests pass, take headless screenshots and review visually. Works on all platforms.
Setup
e2e/ at project root has a minimal package.json with playwright. On first setup:
bash -c 'cd "$(git rev-parse --show-toplevel)/e2e" && mise x -- npm install && mise x -- npx playwright install chromium'
The e2e/ directory is completely separate from frontend/ — the Dockerfile never touches it, so it has no impact on the production image.
Taking screenshots
Confirm the actual Vite port first (auto-increments if 5173 is taken):
lsof -i -P -n -sTCP:LISTEN | grep node | awk '{print $9}'
bash -c 'cd "$(git rev-parse --show-toplevel)/e2e" && mise x -- npx playwright screenshot --viewport-size="1280,720" --full-page http://localhost:5173 /tmp/homepage.png'
For mobile (if PRD requires): --viewport-size="375,812". For authenticated routes: write a script in e2e/ that calls POST /api/dev/login, gets a cookie, navigates, and screenshots. Read screenshots with the Read tool.
Scope of visual check
- Key routes (home, dashboard, main features).
- Authenticated routes (via dev login).
- Pages affected by this session's work.
- Aesthetics — alignment, padding, spacing, readability, contrast.
Fix anything that looks off and re-screenshot before committing.
Local Development Feedback Loop
Write code + tests → run tests → visual check → fix & repeat → update docs → commit & push → wrap up.
Write/Edit Code + Tests
↓
Start services (podman, go run, npm run dev)
↓
Run tests (Layer 1: Go, Layer 2: Vitest + npm run typecheck)
↓
All pass? ──No──► Fix & repeat
↓ Yes
Visual check (screenshots)
↓
Looks right? ──No──► Fix & repeat
↓ Yes
Update docs (TASKS, PRD, ADRs, INFRASTRUCTURE)
↓
Commit & push → Wrap up session
After committing, present the session wrap-up as defined in the cofounder agent.
sqlc workflow
Always follow this order — never skip step 3:
- Write or update the SQL queries in
backend/internal/database/queries/*.sql - Run sqlc generate:
bash -c 'cd "$(git rev-parse --show-toplevel)/backend" && mise x -- sqlc generate' - Read the generated
.gofiles inbackend/internal/database/sqlc/to confirm exact struct/field names before writing handlers. sqlc names are unpredictable:
- Positional parameters with type casts (e.g.,
$2::boolean) becomeColumn2,Column3, etc. — not the column names. - Queries with partial
RETURNINGclauses generate a separate row type (e.g.,UpsertUserRow) distinct from the full model (User). - Assuming field names without reading the output leads to type mismatch errors that require back-and-forth corrections.
- Write the Go handlers using the exact names from the generated files. Never hand-write SQL in Go files.
Conventions
- Thin handlers: parse request → call database → return JSON. No business logic in handlers.
- Logging:
slogexclusively. Neverfmt.Printlnorlog.Println. - Validation: Server-side validation for all inputs. Never trust client-side validation alone.
- Authorization: Checks in every handler, not just middleware.
- Frontend components:
bash -c 'cd "$(git rev-parse --show-toplevel)/frontend" && mise x -- npx shadcn@latest add <component>' - No ORMs. SQL through sqlc only.
- No CSS preprocessors. Tailwind CSS only.
- No additional JavaScript frameworks. React + React Router only.
- No SSR frameworks or Node server runtime. SEO needs are m
…(truncated)