The Framework

From zero to running web application. Every step uses standard Go.

01 — Get Started

Install and Run

Download the binary from the download page, or install with Go. Requires Go 1.25+.

# Download and install
tar -xzf congo-*.tar.gz
sudo mv congo /usr/local/bin/

# Create a project and run it
congo init myapp
cd myapp
congo dev

Open localhost:5000 in your browser — you'll see a welcome page. Edit any Go or template file and the server rebuilds automatically.

This creates a directory with the full framework vendored inside:

myapp/
  internal/          Framework source (yours to read and modify)
    application/     HTTP server, routing, controllers, templates
    database/        ORM, auto-migration, SQLite/LibSQL engines
    router/          Domain routing, TLS, middleware composition
    security/        Nonce, headers, CSP
    frontend/        React islands, esbuild, HMR
    assistant/       AI chat, streaming, tool calling
    platform/        Cloud server management, Docker, SSH
  web/
    controllers/     Your request handlers
    models/          Your data models
    views/           Your HTML templates
      layouts/       Page layouts
      partials/      Reusable components
      static/        CSS, JS, images
    main.go          Entry point
  go.mod
  Dockerfile
  CLAUDE.md          AI context (generated by congo claude)

The framework lives in internal/. It's regular Go code — open any file and read it.

02 — Controllers

Add a Controller

Controllers handle HTTP requests and expose methods to templates. Create web/controllers/todos.go:

package controllers

import (
    "net/http"
    "myapp/internal/application"
    "myapp/web/models"
)

func Todos() (string, *TodosController) {
    return "todos", &TodosController{}
}

type TodosController struct {
    application.BaseController
}

func (c *TodosController) Setup(app *application.App) {
    c.BaseController.Setup(app)
    app.Handle("GET /todos", app.Serve("todos.html", nil))
    app.Handle("POST /todos", app.Method(c, "Create", nil))
}

// Value receiver creates a copy — each request gets its own state.
func (c TodosController) Handle(r *http.Request) application.Controller {
    c.Request = r
    return &c
}

// Public methods are callable from templates: {{todos.All}}
func (c *TodosController) All() []*models.Todo {
    items, _ := models.Todos.All()
    return items
}

func (c *TodosController) Create(w http.ResponseWriter, r *http.Request) {
    todo := &models.Todo{Title: r.FormValue("title")}
    models.Todos.Insert(todo)
    c.Redirect(w, r, "/todos")
}

Register it in web/main.go:

router.Listen(
    router.WithLogger(),
    security.New(security.WithNonce(), security.WithHeaders()),
    application.New(views,
        application.WithController(controllers.Home()),
        application.WithController(controllers.Todos()),
    ),
)

The factory function returns a name and controller. The name becomes the template namespace — todos.All calls the All() method.

Why app.Handle? App embeds Go's *http.ServeMux — so app.Handle and app.HandleFunc are inherited standard library methods, not framework abstractions. No custom router. No wrapper. Just Go.

Why value receiver on Handle()? Go copies the struct when you use a value receiver (c TodosController instead of *TodosController). Each HTTP request gets its own copy, so concurrent requests can't interfere with each other. All other methods use pointer receivers as normal.

03 — Models

Add a Model

Models are Go structs. The ORM creates tables, migrates schemas, and provides type-safe CRUD. Create web/models/todo.go:

package models

import "myapp/internal/database"

type Todo struct {
    database.Model
    Title string
    Done  bool
}

var Todos = database.Manage(DB, new(Todo))

That's it. The table is created on startup. Columns are added automatically when you add struct fields. database.Model provides ID, CreatedAt, and UpdatedAt.

// Insert — generates UUID, sets timestamps
id, err := models.Todos.Insert(&models.Todo{Title: "Ship it"})

// Get by ID
todo, err := models.Todos.Get(id)

// Search with SQL (PascalCase column names)
done, err := models.Todos.Search("WHERE Done = ?", true)

// Update — auto-updates UpdatedAt
todo.Done = true
err = models.Todos.Update(todo)

// Delete
err = models.Todos.Delete(todo)

Why PascalCase SQL? Column names match Go struct fields exactly — Title in the struct becomes Title in SQL. No mapping layer, no tags, no surprises. WHERE Done = ? reads the same as todo.Done.

04 — Views

Write a View

Views are standard Go html/template files with HTMX attributes. Create web/views/todos.html:

{{template "main.html" .}}

{{define "content"}}
<div class="container mx-auto px-8 py-16 max-w-2xl">
    <h1 class="text-3xl font-bold mb-8">Todos</h1>

    <form hx-post="/todos" hx-target="body" class="flex gap-2 mb-8">
        <input name="title" class="input input-bordered flex-1"
               placeholder="What needs doing?" required />
        <button class="btn btn-primary">Add</button>
    </form>

    {{range todos.All}}
    <div class="flex items-center gap-3 py-2">
        <span>{{.Title}}</span>
    </div>
    {{end}}
</div>
{{end}}

Controller methods like todos.All are called directly in templates. HTMX handles form submissions and page updates without JavaScript. DaisyUI provides ready-made components — buttons, forms, cards — just add class names.

Why filename only? Templates reference layouts and partials by filename — {{template "main.html" .}}, not by path. All templates are in a flat namespace. Move files around without updating references.

05 — Testing

Write Tests

Tests get a fresh in-memory database automatically — no setup needed:

package models_test

import (
    "testing"
    "myapp/web/models"
)

func TestTodoInsert(t *testing.T) {
    id, err := models.Todos.Insert(&models.Todo{Title: "Ship it"})
    if err != nil {
        t.Fatal(err)
    }

    todo, err := models.Todos.Get(id)
    if err != nil {
        t.Fatal(err)
    }
    if todo.Title != "Ship it" {
        t.Errorf("got %q, want %q", todo.Title, "Ship it")
    }
}
congo test                     # run all tests
congo test ./web/models/...    # test models only

External dependencies have mock providers built in — assistant/providers/mock for AI and platform/providers/mock for infrastructure.

06 — AI

AI-Assisted Development

congo claude

Launches Claude Code with the full framework reference injected. The AI knows the controller pattern, the model API, the template conventions. It writes code that fits because the framework taught it how.

07 — Deploy

Build and Deploy

congo build    # single binary
congo launch   # build, ship, deploy

congo build compiles your app into a single binary. congo launch builds a Docker image, ships it to your server, and starts it with health checks and automatic rollback.

Your project has three declarations: models/db.go declares what the system remembers (data). main.go declares what the system does (application). Infrastructure declares where the system lives — same pattern, same functional options, different scope.

08 — Fork

Generational Development

Every Congo binary carries the complete source tree inside it:

congo source ./my-framework
cd my-framework
go build -o my-cli ./cmd

Take the source, modify it, ship your own version. When someone runs my-cli source, they get your fork — and can fork it again.

Every binary is a seed for the next version. No central repository. No permission needed. Fork it, improve it, pass it on.

Packages

Framework Packages

Seven packages. Four foundations with no framework dependencies. Three composites that build on them. Use any combination — exclude what you don't need with --no-frontend, --no-assistant, etc.

Foundations

Composites

Stay Updated

Get notified about new releases and updates.