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 incali-coding-go-standards; do not duplicate that policy here. These are enforced viacali-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
.templfiles for HTML - NEVER use
fmt.Sprintfwith 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). Usemasterif the project's default branch ismaster.
.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: descricaofor patch bumpsfeat: descricaofor minor bumpschore:,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):
FTS5 (tokenizer
unicode61 remove_diacritics 2 prefix=3 4): busca por termos com acentuação flexível. Default. Implementado porPBMessageSearcher.SearchFTS5().Embedding semântico (Fase 2): modelo
multilingual-e5-smallONNX int8 (112MB, 384-dim, MIT). Inferência viabenedoc-inc/onnxer. Cosine similarity em Go puro (não SQL). Embeddings armazenados como BLOB em PB collectionmessage_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-signalsem 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
- Signals - Reactive state in the frontend (JavaScript)
- SSE - Server-Sent Events for push updates
- Patches - HTML fragments updated by the server
- 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-workflowvia/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
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-initis optional (not required) - only use when you need to execute an action on page/component load
- Ensure datastar.js v1.0.2 is in
Static Files Configuration
- Use build tags:
//go:build devfor dev,//go:build !devfor prod - In dev:
http.FileServerFS(os.DirFS(StaticDirectoryPath)) - In prod:
embed.FS+hashfs.FileServer - Test:
curl http://localhost:8080/static/datastar/datastar.jsshould return 200
- Use build tags:
Form Migration
- Add
nameattribute to all inputs/textareas - Add
data-bindfor two-way sync - Replace
data-on:input="$var = el.value"withdata-bind="varName"
- Add
JSON Escaping
- Replace manual escape with
json.Marshalapproach - Test with special characters: newlines, quotes, unicode
- Replace manual escape with
Visibility Patterns
- Replace
class="hidden"+data-showwithdata-showonly - Test tab switching
- Replace
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:
- Check json.Marshal escaping
- View page source and validate JSON
- 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:
- Check 404 for datastar.js
- Verify static file serving
- Check script path
"Signals not updating"
Cause: Typically a data-bind or signal definition issue Fix:
- Ensure
data-bindattribute is present on form elements - Check signal name matches between definition and usage
- 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
- Self-contained features - Each feature in its own directory
- Delta sync - Send only what changed (not entire objects)
- KISS (SSE) - Prefer SSE over WebSockets for one-way updates
- Indicators - Always show loading states with
data-indicator - Error boundaries - try/catch on media and storage operations
- 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:scrollin 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.Sprintfwith 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, startgoai.StreamText, return stream object. Caller:.TextStream()for chunks,.Err()after drain.StreamCompletion(ctx, sse, settings, promptID, prompt, targetID, markdown) (string, error)— wrapsstreamTextChunks+ 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 fordata-on-*,data-bind-*,data-attr-*) data-signalsJSON 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 (
__delaywithout.500ms, unknown modifiers)
Form/behavior correctness
data-bindon<input>/<select>/<textarea>withoutnameattribute (form data doesn't send)<form>withdata-bindinputs but nodata-on:submithandlerdata-showmixed withclass="hidden"(visibility conflict)- Alpine.js/Vue.js attributes (
x-*,v-*,:*,@*) data-indicatorpositioned AFTERdata-initon same element (race condition)data-text,data-computed,data-effectwith 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 withoutdeferattribute
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:
- Load the
agent-browserskill — navigate pages, click buttons, verify no JS errors in console. - Load the
dogfoodskill — systematically explore the feature, test edge cases, find bugs. - 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.