cali-coding-go-stack

star 1

[Cali] Go web applications using Datastar, Templ, DaisyUI, TailwindCSS, NATS, and optional features (Fabric.js whiteboard, LiveKit+Gemini voice AI, PocketBase/SQLite). Use when: starting a new Go web project OR evolving/extending an existing one that uses this stack. Triggers for scaffolding ("new go project", "scaffold go app", "go web boilerplate"), real-time work ("datastar go", "templ project", "go sse server", "go realtime app", "hypermedia go"), and feature evolution ("add feature to go app", "refactor go handler", "add NATS", "migrate to Templ", "extend boilerplate", "add auth", "add database", "add whiteboard", "add voice AI"). Also triggers for AI/LLM with voice ("voice AI", "voice bot", "livekit gemini", "real-time voice"). For text-only AI use the goai module in features/ai/ (included in scaffold).

renatocaliari By renatocaliari schedule Updated 6/11/2026

name: cali-coding-go-stack description: > [Cali] Go web applications using Datastar, Templ, DaisyUI, TailwindCSS, NATS, and optional features (Fabric.js whiteboard, LiveKit+Gemini voice AI, PocketBase/SQLite).

Use when: starting a new Go web project OR evolving/extending an existing one that uses this stack. Triggers for scaffolding ("new go project", "scaffold go app", "go web boilerplate"), real-time work ("datastar go", "templ project", "go sse server", "go realtime app", "hypermedia go"), and feature evolution ("add feature to go app", "refactor go handler", "add NATS", "migrate to Templ", "extend boilerplate", "add auth", "add database", "add whiteboard", "add voice AI"). Also triggers for AI/LLM with voice ("voice AI", "voice bot", "livekit gemini", "real-time voice"). For text-only AI use the goai module in features/ai/ (included in scaffold).

Datastar Go SDK v2 Watch

Monitor at start of every session:

  • Issue #8 (OPEN): GetSSE/PostSSE/etc. breaking change to support action options (contentType, openWhenHidden, retry). Maintainers agreed to a v2 SDK. NO PR yet.
  • PR #18 (OPEN): Refactor PatchElement* sugars to be library-agnostic. Backward-compatible wrappers preserved. Pending @delaneyj review.

When v2 is released, alert the user. Today (2026-06): v1.2.2 is the latest stable.

Go Stack Web Application

Go boilerplate inspired by Northstar, featuring:

Engineering Standards

Standards: This skill covers stack-specific patterns only. Concurrency, linting, security, and supply chain rules are in cali-coding-go-standards — both skills apply simultaneously on this stack. Local dev tooling (Air, .air.toml, Makefile dev target) is also defined in cali-coding-go-standards; do not duplicate that policy here. These are enforced via cali-coding-go-standards, which activates simultaneously with this skill.

⚠️ Datastar v1.0.2 Required - This boilerplate uses Datastar v1.0.2 (not RC.8). See Installation below.

  • Datastar - Reactive hypermedia via SSE
  • Templ - Go components that generate HTML
  • DaisyUI + TailwindCSS - UI components and styling
  • NATS - Real-time messaging (optional: JetStream for persistence)
  • Fabric.js - Collaborative whiteboard (optional)
  • LiveKit + Gemini - Voice AI (optional)
  • PocketBase - Advanced database (optional)
    • Driver: modernc.org/sqlite (default). Projeto atual usa ncruces/go-sqlite3 via Config.DBConnect — pure Go, sem CGO (wasm2go). Driver registra como "sqlite3" (modernc é "sqlite"). Não precisa de build tag no_default_driver.
    • Extensões via sqlite3.AutoExtension():
      • ext/unicode → unaccent(), collation pt_BR
      • ext/fts5 → FTS5 (necessário para PB EnsureFTS5Index)
      • ext/spellfix1 → fuzzy matching
      • ⚠️ ext/vec1 NÃO funciona no WASM binary default
    • Busca vetorial: vector math em Go puro (não SQL) — ver seção Estratégia híbrida abaixo.
  • SQLite - Simple database (optional)

When to Activate This Skill

Activate this skill when the user wants:

Intent Example Prompt
Create Go web project from scratch "create a new go web app"
Create new feature in a Go web project "create a feature"
Add real-time/SSE "add real-time updates to my Go app"
UI with ready-made components "add a dashboard with Tailwind components"
Hypermedia-driven app "build like Datastar/HTMX style app in Go"
Voice AI with LiveKit "add a voice assistant to my app"
Collaborative whiteboard "add a collaborative whiteboard"
Database "add persistence with SQLite/PocketBase"

🚨 MANDATORY: templ for ALL HTML

Read this first: MANDATORY_TEMPL_USAGE.md

This project has a ZERO-TOLERANCE policy for HTML in Go source files.

  • ALWAYS create .templ files for HTML
  • NEVER use fmt.Sprintf with HTML tags
  • NEVER use indexed format specifiers (%[N]s)
  • Blocked by CI: grep -r 'fmt\.Sprintf.*<' must return empty

Quick Start

1. Ask the User: Which Features Are Needed?

Not every project needs everything. Use this decision tree:

Go Web Project
├── Need UI?
│   ├── YES → DaisyUI (always included by default)
│   └── NO → Skip to "Need data?"
│
├── Need real-time?
│   ├── Simple Pub/Sub (NATS Core) → fire-and-forget messaging
│   ├── With persistence/history (JetStream) → streams + replay
│   └── Reactive frontend only (Datastar SSE) → no NATS needed
│
├── Need database?
│   ├── Simple (1 instance, local) → SQLite
│   ├── Advanced (multi-instance, auth, REST, realtime) → PocketBase
│   └── None → in-memory data or NATS KV
│
├── Need voice AI?
│   ├── YES → LiveKit + Gemini Live API
│   └── NO → Skip
│
├── Need whiteboard?
│   ├── YES → Fabric.js
│   └── NO → Skip

2. Decision Checklist

Before starting, confirm with the user:

## Project Configuration

- [ ] **UI**: DaisyUI + TailwindCSS (default, always recommended)
- [ ] **Real-time messaging**: NATS Core / JetStream / None
- [ ] **Database**: SQLite / PocketBase / None
- [ ] **Voice AI**: LiveKit + Gemini / None
- [ ] **Whiteboard**: Fabric.js / None
- [ ] **Module name**: `github.com/user/projectname`
- [ ] **Deploy target**: your-server.com / other / none

3. Deploy & Versioning (OPTIONAL)

⚠️ Only generate this section if the user confirms a deploy target.

Ask the user: "Este projeto será deployado em produção? Onde?"

Target Options

Target Action
your-server.com Generate full CI/CD pipeline with ghcr.io + cron
other Generate pipeline with placeholders ({{SERVER_HOST}}, {{IMAGE_NAME}})
none Skip this section entirely

If Deploy Target = your-server.com

Generate these files with concrete values (no placeholders):

Branch note: The examples use main (GitHub default). Use master if the project's default branch is master.

.github/workflows/deploy.yml:

name: Build and Publish Docker Image
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
permissions:
  packages: write
  contents: read
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: docker/setup-buildx-action@v3
      - run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
      - id: version
        run: |
          VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
          echo "version=$VERSION" >> $GITHUB_OUTPUT
      - uses: docker/metadata-action@v5
        with:
          images: ghcr.io/{{GITHUB_REPO}}
          tags: |
            type=ref,event=branch
            type=sha
            type=raw,value=latest
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          build-args: |
            CGO_ENABLED=0
            VERSION=${{ steps.version.outputs.version }}

.github/workflows/release.yml:

name: Release
on:
  push:
    branches: [main]
concurrency:
  group: release-please
  cancel-in-progress: false
permissions:
  contents: write
  pull-requests: write
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: googleapis/release-please-action@v4
        id: release
        with:
          release-type: go
          config-file: release-please-config.json
          manifest-file: .release-please-manifest.json
          target-branch: ${{ github.ref_name }}
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: Auto-merge Release PR
        if: ${{ steps.release.outputs.release_created != 'true' }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          PR_NUMBER="${{ fromJSON(steps.release.outputs.pr).number }}"
          if [ -n "$PR_NUMBER" ]; then
            gh pr merge --merge "$PR_NUMBER"
          fi

Also create release-please-config.json:

{
  "packages": {
    ".": {
      "release-type": "go"
    }
  }
}

And .release-please-manifest.json:

{
  ".": "0.0.0"
}

Important: release-please requires conventional commits format. Agents must use:

  • fix: descricao for patch bumps
  • feat: descricao for minor bumps
  • chore:, docs:, etc. for no bump
  • Avoid emoji prefixes like :bug: fix: — release-please ignores these

⚠️ Critical prs_created trap: The release-please-action outputs prs_created as a boolean (true/false), not a PR number. Using ${{ steps.release.outputs.prs_created }} in shell will expand to <<< "true" and break. Always use steps.release.outputs.pr (actual PR number) instead.

update.sh (on server at /opt/{{APP_NAME}}/update.sh):

#!/bin/bash
set -e
IMAGE="ghcr.io/{{GITHUB_REPO}}"
CONTAINER_NAME="{{APP_NAME}}"
TOKEN_FILE="/opt/{{APP_NAME}}/.gh_token"

log() { echo "$(date): $1" | tee -a /opt/{{APP_NAME}}/update.log; }

log "Checking for updates..."
echo "$(cat $TOKEN_FILE)" | docker login ghcr.io -u ${{ GITHUB_USERNAME }} --password-stdin > /dev/null 2>&1

docker pull "$IMAGE:latest" > /dev/null 2>&1

# Compare image IDs (not manifest digests — multi-arch bug: RepoDigests != pull output)
REMOTE_ID=$(docker inspect "$IMAGE:latest" --format="{{.Id}}" 2>/dev/null || echo "")
LOCAL_ID=$(docker inspect "$CONTAINER_NAME" --format="{{.Image}}" 2>/dev/null || echo "")

if [ -z "$LOCAL_ID" ]; then
    log "Container not running, will start..."
fi

if [ "$REMOTE_ID" != "$LOCAL_ID" ]; then
    log "New version detected ($REMOTE_ID), deploying..."
    docker stop "$CONTAINER_NAME" 2>/dev/null || true
    docker rm "$CONTAINER_NAME" 2>/dev/null || true
    SESSION_SECRET=$(openssl rand -base64 32 2>/dev/null || head -c32 /dev/urandom | base64)
    docker run -d \
        --name "$CONTAINER_NAME" \
        --restart unless-stopped \
        -p 127.0.0.1:8080:8080 \
        -e SESSION_SECRET="$SESSION_SECRET" \
        -v /opt/{{APP_NAME}}/data:/app/data \
        "$IMAGE:latest"
    log "SUCCESS: Deployed new version (image: $REMOTE_ID)"
else
    log "No changes detected, skipping restart"
fi

Dockerfile (multi-stage):

  • Builder: golang:{{GOVSN}}-alpine + templ generate + go build -ldflags="-X main.Version=${VERSION}"
  • Runtime: alpine:3.21 (no root, healthcheck via TCP port check)
  • Expose: 8080

If Deploy Target = other

Same structure but use placeholders that the agent/user must fill:

  • {{IMAGE_NAME}} = full image path
  • {{SERVER_HOST}} = deploy server SSH host
  • {{APP_DIR}} = path on server
  • {{DEPLOY_TOKEN_PATH}} = path to ghcr.io token

Go Configuration Variables

Go projects use environment variables directly (no .env by default). For production, pass via docker run -e VAR=value. For local dev, optionally add github.com/joho/godotenv if user requests .env support.

Version Display in UI

To show version in the app UI, inject via ldflags at build time:

go build -ldflags="-X main.Version=${VERSION}" -o app ./cmd/web/

In Go code:

var Version = "dev"  // set via ldflags in Dockerfile

func main() {
    cfg := config.Load()
    cfg.Version = Version
}

In config.go:

type Config struct {
    // ... other fields
    Version string
}

Pass version to templates via page data structs:

type HomePageData struct {
    Sessions []SessionCardData
    Version  string  // added to struct
}

4. Generated Structure

project/
├── cmd/web/main.go           # Entry point
├── config/
│   ├── config.go             # Configuration
│   ├── config_dev.go         # Dev config
│   └── config_prod.go        # Prod config
├── router/router.go          # Main router
├── nats/nats.go              # NATS setup
├── features/                 # Self-contained features
│   ├── common/
│   │   ├── layouts/base.templ
│   │   └── components/
│   ├── index/                # Home page
│   ├── todos/                # CRUD example (NATS KV)
│   ├── counter/              # Global vs user state
│   ├── monitor/              # System info
│   ├── sortable/             # Lit + SortableJS
│   ├── reverse/              # Streaming demo
│   ├── whiteboard/           # [OPTIONAL] Fabric.js
│   └── voice-training/       # [OPTIONAL] LiveKit + Gemini
├── web/
│   └── resources/
│       └── styles/
│           └── styles.css    # DaisyUI + Tailwind
├── go.mod
└── Taskfile.yml

Feature Modules (Self-Contained)

Each feature in features/<name>/ is 100% self-contained:

  • Its own routes, handlers, services
  • Its own static assets (via go:embed)
  • Its own templates and components

How to Add/Remove Features

# To add: copy the feature directory to the project
cp -r boilerplate-go/assets/scaffold/features/whiteboard myproject/features/

# To remove: delete the directory
rm -rf myproject/features/whiteboard

Pattern: Embedded Assets (go:embed)

// features/whiteboard/static.go
package whiteboard

import (
    "embed"
    "io/fs"
    "net/http"
)

//go:embed static/*
var staticEmbed embed.FS

func StaticFS() http.FileSystem {
    fsys, _ := fs.Sub(staticEmbed, "static")
    return http.FS(fsys)
}

Detailed Architectural Decisions

UI: DaisyUI (Always Recommended)

DaisyUI is ready-made TailwindCSS components. Always recommended for Go web projects because:

  • 0 custom JavaScript for basic UI
  • Consistent themes (automatic light/dark)
  • Accessible components by default
  • Customizable via Tailwind config

When NOT to use DaisyUI:

  • Project needs 100% custom design (UI as differentiator)
  • Team already has their own design system

Real-time: NATS Core vs JetStream

Need Solution
Simple broadcast (1→N) NATS Core
Messages with history JetStream
Work queues JetStream Consumer
Simple Key-Value JetStream KV
High performance, low latency NATS Core

Database: SQLite vs PocketBase

Criteria SQLite PocketBase
Multiple instances
Built-in auth
Automatic REST API
Realtime subscriptions
Migrations Manual Automatic
Simplicity
Local data only

⚠ Database driver note: PB default é modernc.org/sqlite (registra como "sqlite"). Projeto Treinador usa ncruces/go-sqlite3 v0.35.1 (registra como "sqlite3") via Config.DBConnect. Pure Go (wasm2go), sem CGO. Não precisa de build tag.

import (
    "github.com/ncruces/go-sqlite3"
    _ "github.com/ncruces/go-sqlite3/driver"
    "github.com/ncruces/go-sqlite3/ext/fts5"
    "github.com/ncruces/go-sqlite3/ext/spellfix1"
    "github.com/ncruces/go-sqlite3/ext/unicode"
)

sqlite3.AutoExtension(func(c *sqlite3.Conn) error { return unicode.Register(c) })
sqlite3.AutoExtension(spellfix1.Register)
sqlite3.AutoExtension(fts5.Register)

dbConnect := func(dbPath string) (*dbx.DB, error) {
    pragmas := "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=journal_size_limit(200000000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)&_pragma=temp_store(MEMORY)&_pragma=cache_size(-32000)"
    return dbx.Open("sqlite3", dbPath+pragmas)
}
app := pocketbase.NewWithConfig(pocketbase.Config{
    DBConnect: dbConnect,
})

Estratégia híbrida de busca

O projeto usa 2 estratégias via interface MessageSearcher (history_context.go):

  1. FTS5 (tokenizer unicode61 remove_diacritics 2 prefix=3 4): busca por termos com acentuação flexível. Default. Implementado por PBMessageSearcher.SearchFTS5().

  2. Embedding semântico (Fase 2): modelo multilingual-e5-small ONNX int8 (112MB, 384-dim, MIT). Inferência via benedoc-inc/onnxer. Cosine similarity em Go puro (não SQL). Embeddings armazenados como BLOB em PB collection message_embeddings. FakeEmbedder para dev/test.

Por que não sqlite-vec: ext/vec1 do ncruces precisa de custom WASM; sqlite-vec-go-bindings/ncruces usa API sqlite3.Binary removida no ncruces v0.35.1+. Solução: vector math em Go puro, sem custo operacional adicional.

Futuro: hybrid RRF (FTS5 + embedding scores mergidos).

Voice AI: When to Activate

Activate LiveKit + Gemini when:

  • Voice assistant/chatbot that speaks
  • Real-time transcription - meetings, calls
  • Visual assistants - screen sharing + voice
  • Automated IVR/NPS - phone support
  • Remote education - tutoring with voice

Do NOT activate (use features/ai/ goai module instead):

  • Text/chat generation
  • Embeddings
  • Image analysis
  • Function calling without voice

Datastar v1.0.2 Patterns

⚠️ CRITICAL: This boilerplate uses Datastar v1.0.2. Read this section carefully!

Datastar v1.0.2 Installation

Option 1: CDN (Recommended for quick setup)

<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.2/bundles/datastar.js"></script>

Option 2: Self-hosted (Included in boilerplate)

The boilerplate includes datastar.js at web/resources/static/datastar/datastar.js.

<script defer type="module" src="/static/datastar/datastar.js"></script>

Option 3: npm/deno/bun

import 'https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.2/bundles/datastar.js'

HTML Template Setup

Your HTML template must include the Datastar script:

<!DOCTYPE html>
<html lang="en">
<head>
    <script defer type="module" src="/static/datastar/datastar.js"></script>
</head>
<body>
    { children... }
</body>
</html>

Core Attributes

Attribute Example Purpose
data-on:click data-on:click="@post('/api/action')" Click handler
data-init data-init="$count = 1" Optional: executes expression on element init
data-signals data-signals='{"count":0}' 🔴 JSON obrigatório! Reactive state
data-bind data-bind="model" Two-way binding
data-text data-text="$count" Text content
data-class data-class="{'text-primary': $active}" Conditional classes
data-show data-show="$visible" Conditional visibility

⚠️ data-signals em v1.0.2 exige JSON puro. A sintaxe K=V (theme: 'light') foi removida. Sempre usar JSON: '{"count":0}'. Em templ, use backtick: `{"count":0}`.

Signal Philosophy (IMPORTANT)

⚠️ Overusing signals typically indicates trying to manage state on the frontend.

Favor fetching current state from the backend rather than pre-loading and assuming frontend state is current.

Good rule of thumb:

  • ✅ Use signals for user interactions (e.g., toggling element visibility, accordion state)
  • ✅ Use signals for sending new state to backend via form bindings
  • ❌ Don't use signals to store fetched data (fetch it when needed instead)
  • ❌ Don't pre-load entire objects into signals and manage frontend state

Why this matters:

  • Backend is the source of truth, not the frontend
  • Signals are ephemeral - they don't persist across page loads
  • Pre-loaded state can become stale when data changes elsewhere

Form Submissions

Standard forms (small data):

<form data-on:submit={`@post('/api/action', {contentType: 'form'})`}>
    <input name="name" data-bind="name">
    <button type="submit">Submit</button>
</form>

Backend handler:

func handler(w http.ResponseWriter, r *http.Request) {
    sse := datastar.NewSSE(w, r)
    // For production with large SSE payloads, enable compression:
    // sse := datastar.NewSSE(w, r, datastar.WithCompression())
    r.ParseForm() // Required for form contentType
    name := r.FormValue("name")
    // ...
}

⚠️ Important: Always use {contentType: 'form'} for form submissions to send form-encoded data, not JSON signals.

What NOT to Use

  • ❌ HTMX
  • ❌ Alpine.js
  • ❌ Vanilla JavaScript to replace Datastar functionality
  • ❌ Other reactive frameworks

⚠️ Read references/datastar/patterns.md for complete patterns.

Key Concepts

  1. Signals - Reactive state in the frontend (JavaScript)
  2. SSE - Server-Sent Events for push updates
  3. Patches - HTML fragments updated by the server
  4. Indicators - Visual loading states

Example: Counter

// Backend: signals and patches
type CounterSignals struct {
    Global uint32 `json:"global"`
    User   uint32 `json:"user"`
}

templ Counter(signals CounterSignals) {
    <div data-signals={ templ.JSONString(signals) }}>
        <button data-on:click={ datastar.PostSSE("/counter/increment/global") }>
            + Global
        </button>
        <span data-text="$global"></span>
    </div>
}

Example: TodoMVC (NATS KV)

// Backend: CRUD with NATS KV
js.Set("todos", "key", []byte(jsonData))

// Frontend: reactive signals
<div data-signals={ templ.JSONString(TodoSignals{
    Todos: []*Todo{},
    Mode:  "all",
}) }>
    <ul>
        for _, todo := range mvc.Todos {
            @TodoRow(todo)
        }
    </ul>
</div>

💡 Skill complementar: Para planejamento estratégico completo (shaping, análise de riscos, planejamento técnico, gates de qualidade), use a skill cali-product-workflow via /skill:cali-product-workflow. Ela gerencia todo o workflow de produto — este boilerplate cobre a parte de implementação.


References (Progressive Disclosure)

Reference When to Read What It Contains
references/README.md First Index with reading path
references/templ/rules.md Before generating ANY HTML Zero-tolerance rules, anti-patterns, CI enforcement for Templ
references/datastar/patterns.md When using Datastar Signals, SSE, events, indicators
references/datastar/pitfall.md When Datastar behaves unexpectedly Known pitfalls and fixes
references/datastar/toast.md When adding notifications Backend-driven toasts (zero JS, animated)
references/datastar/versus_javascript.md When deciding JS vs Datastar Decision matrix per browser feature
references/daisyui/datastar-integration.md When combining DaisyUI + Datastar Integration rules and pitfalls (modal, show, signals)
DaisyUI llms.txt When needing any DaisyUI component All components, class names, syntax — fetch via curl for current info
references/nats/when-to-use-jetstream.md When configuring real-time NATS Core vs JetStream vs KV
references/voice-ai/when-to-use.md When adding voice AI LiveKit + Gemini
references/whiteboard/fabric_patterns.md When using whiteboard Fabric.js + synchronization
references/pii-masking/cloakpipe.md When sending PII to any LLM (supervision, triage, analysis) CloakPipe sidecar setup, BR PII patterns (CPF/CNPJ/CEP/phone), Go integration
references/context-management/strategy.md When prompts grow beyond context window limits Sliding window + FTS5/BM25 + contextwindow hybrid strategy for long sessions
references/database/README.md When choosing a database SQLite vs PocketBase decision guide
references/database/database.go + user_crud_example.templ When implementing DB layer Concrete setup and CRUD example
references/examples/ When implementing a specific UI pattern Active search, click-to-edit, counter, file upload, infinite scroll, lazy load, todo MVC, whiteboard
references/datastar-lint/main.go After templ generate or debugging Datastar attrs Validates Datastar HTML attributes: typos, JSON, modifiers, actions, Alpine/Vue attr detection
references/ci/docker-cache.md When setting up CI / Dockerfile for Go apps Fast Docker builds (cache mounts + GHA backend), Go + Rust patterns, anti-patterns validated in production
cali-product-workflow/references/tech-planning/generation-principles.md Sempre Princípios de geração de código (KISS, DRY, LoB, SoC)

Working with Existing Projects

When updating an existing Go project to use Datastar v1, follow this checklist:

Migration Checklist

  1. Datastar Script Setup

    • Ensure datastar.js v1.0.2 is in web/resources/static/datastar/
    • Add to HTML: <script defer type="module" src="/static/datastar/datastar.js"></script>
    • data-init is optional (not required) - only use when you need to execute an action on page/component load
  2. Static Files Configuration

    • Use build tags: //go:build dev for dev, //go:build !dev for prod
    • In dev: http.FileServerFS(os.DirFS(StaticDirectoryPath))
    • In prod: embed.FS + hashfs.FileServer
    • Test: curl http://localhost:8080/static/datastar/datastar.js should return 200
  3. Form Migration

    • Add name attribute to all inputs/textareas
    • Add data-bind for two-way sync
    • Replace data-on:input="$var = el.value" with data-bind="varName"
  4. JSON Escaping

    • Replace manual escape with json.Marshal approach
    • Test with special characters: newlines, quotes, unicode
  5. Visibility Patterns

    • Replace class="hidden" + data-show with data-show only
    • Test tab switching

Common Migration Issues

See Common Pitfalls section below.


Common Pitfalls

1. data-signals JSON Escaping

❌ Don't: Manual escaping with strings.ReplaceAll

s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "'", "\\'")

✅ Do: Use json.Marshal

func escapeForJS(s string) string {
    b, _ := json.Marshal(s)
    return string(b)
}

Note: json.Marshal returns the string with quotes included, so use %s directly in templates, not '%s'.

2. Form Data Not Sending

❌ Don't: Inputs without name attribute

<input data-bind="model">

✅ Do: Always include name

<input name="model" data-bind="model">

3. Textareas Not Syncing

❌ Don't: Only data-on:input

<textarea data-on:input="$prompt = el.value">

✅ Do: Use data-bind

<textarea name="prompt" data-bind="prompt">

4. Tabs Not Working

❌ Don't: Mix class="hidden" with data-show

<div data-show="$tab === 'a'" class="hidden">

✅ Do: Use only data-show

<div data-show="$tab === 'a'">

5. Loading State Stuck

❌ Don't: Forget to reset signal

// Handler only saves, doesn't reset
repo.Save(data)

✅ Do: Reset signal after operation

repo.Save(data)
sse.MarshalAndPatchSignals(map[string]bool{"isLoading": false})

Troubleshooting

"Invalid or unexpected token" in console

Cause: Malformed data-signals JSON Fix:

  1. Check json.Marshal escaping
  2. View page source and validate JSON
  3. Check for unescaped newlines/quotes

"POST body empty"

Cause: Inputs missing name attribute Fix: Add name="fieldName" to all form inputs

"datastar not defined"

Cause: Script not loaded Fix:

  1. Check 404 for datastar.js
  2. Verify static file serving
  3. Check script path

"Signals not updating"

Cause: Typically a data-bind or signal definition issue Fix:

  1. Ensure data-bind attribute is present on form elements
  2. Check signal name matches between definition and usage
  3. Verify server returns proper SSE response

Handler never called

Cause: Wrong data-on:click syntax Fix: Use @post('/api/action') format with quotes and leading slash

Form data not received at backend

Cause: Missing {contentType: 'form'} in action Fix: Use @post('/api/action', {contentType: 'form'}) and call r.ParseForm() in handler


Best Practices

  1. Self-contained features - Each feature in its own directory
  2. Delta sync - Send only what changed (not entire objects)
  3. KISS (SSE) - Prefer SSE over WebSockets for one-way updates
  4. Indicators - Always show loading states with data-indicator
  5. Error boundaries - try/catch on media and storage operations
  6. View transitions - Use @view-transition { navigation: auto; } for smooth navigation

Engineering Standards (CRITICAL — Always Enforce)

This project follows strict engineering principles. Violations block merge.

See cali-coding-go-standards for file (500 lines) and function (100 lines) limits that apply to all Go projects.

1. DRY: Zero-tolerance for SSE boilerplate

Never write this pattern manually:

var buf strings.Builder
component.Render(context.Background(), &buf)
sse.PatchElements(buf.String(), datastar.WithSelectorID("target"), datastar.WithModeInner())

Instead, use the shared helper on NarrativeService:

ns.renderAndPatch(sse, component, datastar.WithSelectorID("target"), datastar.WithModeInner())

Extract shared helpers BEFORE adding new code — never repeat patterns.

2. LoC (Locality of Behavior) for Datastar

Prefer data-show, data-class, data-on:* over JavaScript DOM manipulation. Only write JavaScript for things ONLY JS can do:

  • Web Speech API (microphone recording)
  • Drag-to-resize (60fps pointer tracking)
  • Scroll position tracking (no data-on:scroll in Datastar Free)
  • Clipboard (Pro: @clipboard(), Free: JS)

For visual feedback (class toggling, show/hide): ALWAYS use Datastar signals. Never classList.add/remove. Small JS helpers should be colocated in Templ files via <script> blocks, not separate .js files.

3. fmt.Sprintf for HTML is forbidden

All HTML rendering MUST use Templ components. If existing fmt.Sprintf HTML is found, convert to Templ before adding new code.

4. Before adding any code

  • Check if the same pattern already exists as a helper
  • Check if fmt.Sprintf with HTML tags can be replaced with a Templ component
  • For file (500 lines) and function (100 lines) limits, see cali-coding-go-standards

Datastar + LLM Streaming Pattern (Critical for SSE Reliability)

Principle: EVERY LLM call with SSE access MUST use streaming (goai.StreamText), regardless of output size. The PRIMARY goal is keeping the SSE connection alive (data flows every 80ms via throttle), NOT displaying progress.

Bug scenario: Without streaming, the SSE handler blocks for 30-60s while the LLM generates a response. With no data flowing, proxies/browsers close the connection. Datastar's automatic @get retry fires → duplicate LLM calls loop.

Architecture

streamTextChunks(ctx, settings, promptID, prompt)
  → mask PII → goai.StreamText → returns *goai.TextStream
  ↑
  |__ StreamCompletion (patches DOM via SSE) — VISIBLE mode
  |      Supervision, client simulation, discussion
  |      Target: #bubble-{id} (inside chat bubble with bg-primary)
  |
  |__ streamTextChunks directly + MarshalAndPatchSignals — HIDDEN mode
  |      Case summary, block summary
  |      Target: #*-buffer (div display:none)
  |      Only reveals final result when stream completes

How to use

Visible (default):

// Render streaming bubble first, then stream into it
ns.renderAndPatch(sse, chat.StreamingBubble(id, timestamp), ...)
response, err := StreamCompletion(ctx, sse, settings, promptID, prompt,
    "bubble-"+id,  // target: inner of the styled bubble
    true,            // markdown render
)
// On success: replace streaming wrapper with final ChatBubble
// On error: render StreamingError (+ retry button)

Hidden (buffer):

// Add <div id="my-buffer" style="display:none"> to template
response, err := StreamCompletion(ctx, sse, settings, promptID, prompt,
    "my-buffer",  // target: hidden div
    false,          // markdown: only plain text needed
)
// On success: re-render visible DOM or update signal
// On error: show toast, set loading signals to false

Retry button on error

StreamingError accepts retryAction parameter (Datastar expression):

StreamingError(id, message, timestamp, retryAction)

If non-empty, renders a "Try again" button firing the action. For supervision: "$isSending = true; @post('/api/ui/action?action=send_message')"

Error toast duration

Errors stay 8s (vs 3s default): implemented via toastDuration(toastType) in toast.templ — if toastType == "error", data-init__delay.8s.

Conventions

Context Target Markdown Visible Example
Chat bubble bubble-{id} true Supervision, client, discussion
Panel/editor {target}-buffer false Case summary, block summary
Signal direct streamTextChunks+signals N/A Block summary (legacy)

DRY helpers in llm_goai.go

  • streamTextChunks(ctx, settings, promptID, prompt) (*goai.TextStream, error) — core: mask PII, start goai.StreamText, return stream object. Caller: .TextStream() for chunks, .Err() after drain.
  • StreamCompletion(ctx, sse, settings, promptID, prompt, targetID, markdown) (string, error) — wraps streamTextChunks + DOM patching (throttle 80ms). Returns unmasked accumulated text after stream end.

Datastar HTML Validation (Recommended)

READ references/datastar-lint/main.go when setting up a new Go+Datastar project or when debugging Datastar attribute issues. This is a standalone Go tool that validates Datastar HTML attributes in generated output.

Run after templ generate to catch:

Attribute correctness

  • Unknown/misspelled data-* attributes (30+ real typos mapped, plus dynamic colon-vs-hyphen detection for data-on-*, data-bind-*, data-attr-*)
  • data-signals JSON syntax errors AND unescaped single quotes (' breaks HTML attribute boundary when rendered by templ)
  • Pro-only attributes used without license (data-animate, data-persist, etc.)
  • Invalid modifier syntax (__delay without .500ms, unknown modifiers)

Form/behavior correctness

  • data-bind on <input>/<select>/<textarea> without name attribute (form data doesn't send)
  • <form> with data-bind inputs but no data-on:submit handler
  • data-show mixed with class="hidden" (visibility conflict)
  • Alpine.js/Vue.js attributes (x-*, v-*, :*, @*)
  • data-indicator positioned AFTER data-init on same element (race condition)
  • data-text, data-computed, data-effect with empty expressions

Backend action correctness

  • datastar.PostSSE() used in browser JS instead of @post() (Go SDK function called client-side)
  • GET requests with mutation-like URLs (@get('/api/save') should be @post)
  • Action URLs missing leading /
  • File upload forms without enctype="multipart/form-data"
  • Datastar <script> tag without defer attribute

Reference tool: references/datastar-lint/main.go

# Install locally (requires Go 1.24+)
cd references/datastar-lint && go install .

# Run after templ generate
datastar-lint -r ./web/

# Strict mode (also checks Pro attr usage, etc.)
datastar-lint -r -s ./features/

Add to CI or Air post_cmd:

templ generate && datastar-lint -r ./web/

Testing Protocol (MANDATORY — Frontend Changes)

After any browser-facing change:

  1. Load the agent-browser skill — navigate pages, click buttons, verify no JS errors in console.
  2. Load the dogfood skill — systematically explore the feature, test edge cases, find bugs.
  3. Only then consider the feature complete. Do NOT skip browser testing.

Use skill("agent-browser") and skill("dogfood") tools to load them.


Inspiration

This boilerplate is inspired by Northstar by Delaney Johnson.

Install via CLI
npx skills add https://github.com/renatocaliari/agent-sync-public --skill cali-coding-go-stack
Repository Details
star Stars 1
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator
renatocaliari
renatocaliari Explore all skills →