CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
ReadySite is an open-source platform that makes it easy for anyone to build, launch, and manage websites. Inspired by WordPress, PocketBase, and n8n, ReadySite is built for a world where AI lets anyone create software — the remaining hurdle is getting it live. ReadySite eliminates the DevOps barrier so you can go from idea to running site without managing servers, containers, or infrastructure. Built with Go for speed and reliability.
Ready, Site, Go!
Commands
# Run tests
go test ./... # All tests
go test -v ./pkg/database/... # Verbose, single package
go test -run TestCollection ./... # Single test by name
go test -v -run TestCollection/Insert ./pkg/database # Subtest
# Run applications locally
go run ./example # Example app on :5000
go run ./website # Website CMS on :5000
# Deploy to cloud
go run ./cmd/launch # Deploy all servers
go run ./cmd/launch --server app-1 # Deploy specific server
go run ./cmd/launch --new example # Create new server
# Connect to servers
go run ./cmd/connect # SSH (auto-selects single server)
go run ./cmd/connect docker ps # Run command on server
# Publish website image to registry
go run ./cmd/publish --skip-dockerhub --build-server website-2 # Build on website-2, push to registry
go run ./cmd/publish --skip-dockerhub # Push existing local image to registry
go run ./cmd/publish # Push to both registry and Docker Hub
go run ./cmd/publish --tag v1.0.0 # With specific tag
Architecture
Five composable layers:
| Layer | Package | Purpose |
|---|---|---|
| Database | pkg/database |
Type-safe SQLite CRUD with Collection[E] |
| Application | pkg/application |
MVC web framework with HTMX/HATEOAS |
| Frontend | pkg/frontend |
Interactive JavaScript islands (React) |
| Platform | pkg/platform |
Cloud deployment (DO, AWS, GCP) |
| Assistant | pkg/assistant |
AI provider abstraction (OpenAI, Anthropic) |
Key Conventions
- IDs are ALWAYS strings (UUIDs, never integers)
- SQL uses PascalCase -
WHERE UserID = ?notuser_id - Templates by filename -
{{template "card.html" .}}not path - Controller access -
{{home.Method}}not{{.home.Method}} - Error handling - Use
c.RenderError(w, r, err)for HTMX-compatible errors - Zero values - Use
cmp.Or(value, default)for defaults
Controller Pattern
// Factory returns (name, controller) tuple
func Home() (string, *HomeController) {
return "home", &HomeController{}
}
type HomeController struct {
application.BaseController
}
func (c *HomeController) Setup(app *application.App) {
c.BaseController.Setup(app)
http.Handle("GET /", app.Serve("index.html", nil))
http.Handle("GET /api/data", app.Method(c, "GetData", nil))
http.Handle("POST /api/data", app.Method(c, "Create", RequireAuth)) // With bouncer
}
// Handle - VALUE receiver for request isolation (critical!)
func (c HomeController) Handle(r *http.Request) application.Controller {
c.Request = r
return &c
}
// Public methods accessible in templates as {{home.Message}}
func (c *HomeController) Message() string {
return "Hello, World!"
}
Bouncers (Route Guards)
func RequireAuth(app *application.App, w http.ResponseWriter, r *http.Request) bool {
if GetCurrentUser(r) == nil {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return false
}
return true
}
// Usage in Setup()
http.Handle("GET /admin", app.Serve("admin.html", RequireAuth))
http.Handle("POST /delete", app.Method(c, "Delete", RequireAdmin))
Model Pattern
Models use package-level initialization:
// models/db.go
var (
DB = engines.NewAuto() // Selects engine based on DB_URL/DB_PATH env vars
Posts = database.Manage(DB, new(Post))
)
// models/post.go
type Post struct {
database.Model
Title string
Content string
}
Collection Methods
| Method | Returns | Description |
|---|---|---|
Get(id) |
(*E, error) |
Find by ID |
First(where, args...) |
(*E, error) |
Single result |
Search(where, args...) |
([]*E, error) |
Multiple results |
All() |
([]*E, error) |
All records |
Insert(entity) |
(string, error) |
Create (generates UUID, returns ID) |
Update(entity) |
error |
Save changes |
Delete(entity) |
error |
Remove |
SQL Injection Prevention (CRITICAL)
Always use parameterized queries:
// CORRECT - parameterized (safe)
Posts.Search("WHERE UserID = ? AND Status = ?", userID, status)
// WRONG - string concatenation (SQL injection!)
Posts.Search("WHERE Name = '" + userInput + "'") // NEVER DO THIS
Template Usage
<!-- views/layouts/base.html -->
<!DOCTYPE html>
<html>
<body>{{block "content" .}}{{end}}</body>
</html>
<!-- views/index.html -->
{{template "base.html" .}}
{{define "content"}}
<h1>{{home.Message}}</h1>
{{range $post := home.Posts}}
<div>{{$post.Title}}</div>
{{else}}
<p>No posts yet.</p>
{{end}}
{{end}}
Template directories:
views/layouts/- Pre-loaded at startupviews/partials/- Pre-loaded, use{{template "card.html" .}}views/static/- Served as static filesviews/**/*.html- Loaded on-demand
RenderError Returns 200 OK
RenderError returns HTTP 200 intentionally - required for HTMX to swap error HTML into target elements.
func (c *SiteController) Create(w http.ResponseWriter, r *http.Request) {
site, err := createSite(r)
if err != nil {
log.Printf("[sites] Create failed: %v", err) // Always log
c.RenderError(w, r, err) // Returns 200 with error HTML
return
}
c.Redirect(w, r, "/sites/"+site.ID)
}
SSE Streaming
func (c *HomeController) Live(w http.ResponseWriter, r *http.Request) {
stream := c.Stream(w)
for {
select {
case <-r.Context().Done():
return
case msg := <-updates:
stream.Render("messages", "partials/message.html", msg)
}
}
}
<div hx-ext="sse" sse-connect="/api/live" sse-swap="messages">Loading...</div>
Interactive Islands
For rich components (editors, charts), use React islands:
application.Serve(views,
frontend.WithBundler(&esbuild.Config{
Entry: "index.ts",
Include: []string{"components"},
}),
)
{{render "Counter" (dict "initial" 5)}}
Use HTMX for forms/navigation, React islands for complex interactivity.
Platform & Deployment
p, err := digitalocean.New(token)
server, err := p.CreateServer(platform.ServerOptions{
Name: "app-1",
Size: platform.Small,
Region: platform.SFO,
})
server.WaitForSSH(5 * time.Minute)
server.Copy("./build/app", "/usr/local/bin/app")
infra.json
{
"platform": {"provider": "digitalocean", "token": "$DIGITAL_OCEAN_API_KEY"},
"servers": {"app": {"size": "small", "binaries": ["example"]}},
"binaries": {"example": {"source": "./example", "ports": [{"host": 80, "container": 8080}]}}
}
Each binary needs a Dockerfile in its source directory.
Assistant Layer
AI provider abstraction for OpenAI and Anthropic:
import "github.com/readysite/readysite/pkg/assistant/providers/openai"
client := openai.New(apiKey)
response, err := client.Chat(ctx, []assistant.Message{
{Role: "user", Content: "Hello"},
})
Providers: mock (testing), openai, anthropic
Environment Variables
| Variable | Required | Description |
|---|---|---|
PORT |
No | Server port (default: 5000) |
ENV |
No | "production" disables HMR, sanitizes errors |
AUTH_SECRET |
Production | JWT signing secret (random if unset, fatals in production) |
DB_URL |
No | Remote libSQL server URL (e.g., libsql://mydb.turso.io) |
DB_TOKEN |
No | Authentication token for remote libSQL server |
DB_PATH |
No | Local database file path (e.g., /data/app.db) |
TLS_CERT |
No | Path to TLS certificate file (enables HTTPS on :443) |
TLS_KEY |
No | Path to TLS private key file (required with TLS_CERT) |
RESEND_API_KEY |
No | Resend email API key (falls back to log-only emailer) |
ADMIN_EMAIL |
No | Auto-create admin user on first run (website CMS) |
ADMIN_NAME |
No | Admin display name (defaults to email prefix) |
SITE_NAME |
No | Site name for CMS seeding |
SITE_DESC |
No | Site description for CMS seeding |
HOSTING_URL |
No | Redirect URL when website setup is incomplete |
REGISTRY_HOST |
No | Docker registry hostname (default: registry:5000) |
STRIPE_API_KEY |
No | Stripe secret key (sk_test_... or sk_live_...). Enables Stripe Checkout. |
STRIPE_WEBHOOK_SECRET |
No | Stripe webhook endpoint signing secret (whsec_...) |
STRIPE_HOBBY_PRICE_ID |
No | Stripe Price ID for Hobby plan (price_...) |
STRIPE_PRO_PRICE_ID |
No | Stripe Price ID for Pro plan (price_...) |
DIGITAL_OCEAN_API_KEY |
Deploy | DigitalOcean API token |
DIGITAL_OCEAN_PROJECT |
Deploy | DigitalOcean project UUID |
Database Selection: engines.NewAuto() selects based on environment:
DB_URL+DB_TOKEN→ Remote replica (Turso/libSQL)DB_PATHonly → Local file- Neither → In-memory
Documentation
Developer docs (in this repo):
docs/database.md- Collections, queries, enginesdocs/application.md- Controllers, templates, SSE, bouncersdocs/frontend.md- Interactive islands, bundlerdocs/platform.md- Servers, deployment, infra.jsondocs/assistant.md- AI providers, tool calling, streamingdocs/hosting.md- Hosting app architecturedocs/deployment.md- infra.json, Dockerfiles, environment setupdocs/security.md- Auth, rate limiting, input validationdocs/testing.md- Database, controller, AI testing patternsdocs/examples.md- Recipes and code patternsdocs/troubleshooting.md- Common issues and solutionsdocs/architecture.md- System design and layer compositiondocs/changelog.md- Version history and migration noteswebsite/CLAUDE.md- AI-native CMS specific patterns
User-facing docs (readysite.org website):
readysite.org/views/docs/docker.html- Getting Started guidereadysite.org/views/docs/api.html- API Referencereadysite.org/views/docs/collections.html- Collections guide
IMPORTANT: When adding features or changing configuration, update BOTH the developer docs (docs/*.md) AND the user-facing docs (readysite.org/views/docs/*.html). The .org docs are what users see at readysite.org.
Common Pitfalls
- Value vs Pointer Receivers -
Handle()uses value receiver for request isolation; all other methods use pointer receivers - Template Access - Use
{{home.Method}}not{{.home.Method}}(controllers are functions, not data) - Controller Order - Register catch-all routes last (e.g.,
/{handle}profile routes) - CGO and libsql - Database requires CGO; use
golang:1.24-bookwormfor builds,debian:bookworm-slimfor runtime - Provider Errors - All provider
New()functions return(*Platform, error) - Package-level Models - Initialize DB and collections as package vars, not in init() or main()
- Collection Search - First argument is WHERE clause:
Posts.Search("WHERE Published = ?", true)not just"Published = ?" - SQL Injection - Always use
?placeholders, never concatenate user input into SQL