skills/boilerplate-go/SKILL.md
Scaffolds Go web applications with Datastar, Templ, DaisyUI, TailwindCSS, NATS, and optional features (Fabric.js whiteboard, LiveKit+Gemini voice AI, PocketBase/SQLite database). Use when starting a new Go web project, creating real-time features, building hypermedia-driven apps, or scaffolding Go backend with SSE support. Triggers for "new go project", "scaffold go app", "go web boilerplate", "datastar go", "templ project", "go realtime app", "hypermedia go", "go sse server", "tailwind daisyui", "go ui components", or when user wants Go backend with reactive frontend. Also triggers for AI/LLM integration needs when involving voice: "voice AI", "voice bot", "livekit gemini", "real-time voice", "audio AI". For text-only AI use the goai skill instead.
npx skillsauth add renatocaliari/agent-sync-public-skills boilerplate-goInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Go boilerplate inspired by Northstar, featuring:
⚠️ Datastar v1.0.0 Required - This boilerplate uses Datastar v1.0.0 (not RC.8). See Installation below.
Activate this skill when the user wants:
| Intent | Example Prompt | |--------|----------------| | Create Go web project from scratch | "create a new go web app" | | 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" |
Read this first: MANDATORY_TEMPL_USAGE.md
This project has a ZERO-TOLERANCE policy for HTML in Go source files.
.templ files for HTMLfmt.Sprintf with HTML tags%[N]s)grep -r 'fmt\.Sprintf.*<' must return emptyNot 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
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**: server.renatocaliari.com / other / none
⚠️ Only generate this section if the user confirms a deploy target.
Ask the user: "Este projeto será deployado em produção? Onde?"
| Target | Action |
|--------|--------|
| server.renatocaliari.com | Generate full CI/CD pipeline with ghcr.io + cron |
| other | Generate pipeline with placeholders ({{SERVER_HOST}}, {{IMAGE_NAME}}) |
| none | Skip this section entirely |
server.renatocaliari.comGenerate 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: descricao for patch bumpsfeat: descricao for minor bumpschore:, docs:, etc. for no bump: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 renatocaliari --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):
golang:{{GOVSN}}-alpine + templ generate + go build -ldflags="-X main.Version=${VERSION}"alpine:3.21 (no root, healthcheck via TCP port check)8080otherSame 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 tokenGo 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.
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
}
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
Each feature in features/<name>/ is 100% self-contained:
go:embed)# 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
// 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)
}
DaisyUI is ready-made TailwindCSS components. Always recommended for Go web projects because:
When NOT to use DaisyUI:
| 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 |
| Criteria | SQLite | PocketBase | |----------|--------|------------| | Multiple instances | ❌ | ✅ | | Built-in auth | ❌ | ✅ | | Automatic REST API | ❌ | ✅ | | Realtime subscriptions | ❌ | ✅ | | Migrations | Manual | Automatic | | Simplicity | ✅ | ✅ | | Local data only | ✅ | ❌ |
Activate LiveKit + Gemini when:
Do NOT activate (use goai SDK instead):
⚠️ CRITICAL: This boilerplate uses Datastar v1.0.0. Read this section carefully!
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"></script>
The boilerplate includes datastar.js at web/resources/static/datastar/datastar.js.
<script defer type="module" src="/static/datastar/datastar.js"></script>
import 'https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js'
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>
| 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}" | 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 |
⚠️ 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:
Why this matters:
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)
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.
⚠️ Read references/datastar/patterns.md for complete patterns.
// 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>
}
// 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>
| Reference | When to Read | What It Contains |
|-----------|--------------|------------------|
| references/README.md | First | Index with reading path |
| references/datastar/patterns.md | When using Datastar | Signals, SSE, events, indicators |
| references/datastar/toast.md | When adding notifications | Backend-driven toasts (zero JS, animated) |
| references/JS_VS_DATASTAR_DECISIONS.md | When deciding JS vs Datastar | Decision matrix per browser feature |
| references/daisyui/components.md | When using UI | Ready-to-copy components |
| DaisyUI llms.txt | When you need current component docs | Official DaisyUI LLM reference — fetch via curl for up-to-date info |
| references/nats/when-to-use-jetstream.md | When configuring real-time | NATS 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/database/README.md | When adding DB | SQLite vs PocketBase |
When updating an existing Go project to use Datastar v1, follow this checklist:
Datastar Script Setup
web/resources/static/datastar/<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 loadStatic Files Configuration
//go:build dev for dev, //go:build !dev for prodhttp.FileServerFS(os.DirFS(StaticDirectoryPath))embed.FS + hashfs.FileServercurl http://localhost:8080/static/datastar/datastar.js should return 200Form Migration
name attribute to all inputs/textareasdata-bind for two-way syncdata-on:input="$var = el.value" with data-bind="varName"JSON Escaping
json.Marshal approachVisibility Patterns
class="hidden" + data-show with data-show onlySee Common Pitfalls section below.
❌ 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'.
❌ Don't: Inputs without name attribute
<input data-bind="model">
✅ Do: Always include name
<input name="model" data-bind="model">
❌ Don't: Only data-on:input
<textarea data-on:input="$prompt = el.value">
✅ Do: Use data-bind
<textarea name="prompt" data-bind="prompt">
❌ 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'">
❌ 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})
Cause: Malformed data-signals JSON Fix:
Cause: Inputs missing name attribute
Fix: Add name="fieldName" to all form inputs
Cause: Script not loaded Fix:
Cause: Typically a data-bind or signal definition issue Fix:
data-bind attribute is present on form elementsCause: Wrong data-on:click syntax
Fix: Use @post('/api/action') format with quotes and leading slash
Cause: Missing {contentType: 'form'} in action
Fix: Use @post('/api/action', {contentType: 'form'}) and call r.ParseForm() in handler
data-indicator@view-transition { navigation: auto; } for smooth navigationThis project follows strict engineering principles. Violations block merge.
No .go, .templ, or .js file shall exceed 500 lines.
handlers/chat.go (god function) → chat_send.go + chat_load.go + chat_types.godb/repository.go (all repos) → session_repo.go + message_repo.go + settings_repo.go + feedback_repo.goNever 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.
No function shall exceed 100 lines.
Functions >100 lines MUST be refactored into smaller focused functions before adding new logic.
Common god functions: sendChatMessage, HandleChat, large handler switch statements.
Prefer data-show, data-class, data-on:* over JavaScript DOM manipulation.
Only write JavaScript for things ONLY JS can do:
data-on:scroll in Datastar Free)@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.
fmt.Sprintf for HTML is forbiddenAll HTML rendering MUST use Templ components. If existing fmt.Sprintf HTML is found, convert to Templ before adding new code.
fmt.Sprintf with HTML tags can be replaced with a Templ componentAfter any browser-facing change:
agent-browser skill — navigate pages, click buttons, verify no JS errors in console.dogfood skill — systematically explore the feature, test edge cases, find bugs.Use skill("agent-browser") and skill("dogfood") tools to load them.
This boilerplate is inspired by Northstar by Delaney Johnson.
tools
Auto-initialize structured documentation for any project using lat.md (knowledge graph of markdown files with [[wiki links]], // @lat: code refs, and semantic search). Detects cali-product-workflow artifacts (spec-product.md, spec-tech.md, critiques) and uses them as seed material. Falls back to extracting business rules, architecture, and design decisions directly from the codebase. Use when a project lacks structured documentation or when lat.md/ is missing. After seeding, lat.md extension hooks keep documentation alive automatically.
testing
[Cali] Server security audit and hardening for private servers behind Tailscale. Use when: auditing server security, hardening SSH/firewall/Docker, checking for vulnerabilities, setting up fail2ban, reviewing port exposure, or responding to security alerts. Covers 6 layers: CloudFlare, UFW, Tailscale, SSH, Docker, Application. Triggers: "server security", "security audit", "harden server", "SSH hardening", "firewall rules", "UFW config", "fail2ban", "port security", "Docker security", "vulnerability check", "security review".
tools
Run supply chain security scans before installing packages or before releases. Triggers when: user installs a package (npm, pip, go get, brew), user asks to 'scan dependencies', 'check vulnerabilities', 'supply chain', 'security audit', 'run trivy', 'run socket', or before any release/deployment. Also triggers on mentions of: socket.dev, trivy, OSV-scanner, dotenvx, CVE, dependency audit. Covers all four tools with concrete commands.
tools
Create GitHub releases following project conventions. Triggers when: user says 'release', 'create release', 'push release', 'deploy to main', 'merge to main', user merges a PR to main, or when git push to main is detected. Also triggers on mentions of: gh release, semver, version bump, changelog, release-please. Covers: config-driven (read .release.yml and execute) and fallback (gh CLI) release flows, versioning rules, tag management, and the mandatory release-on-merge convention.