| name | wails |
| description | Expert skill for building desktop applications in Go with Wails, covering both v2 (stable) and v3 (alpha). Use whenever a Go desktop app, webview app, or GUI is being built, reviewed, or debugged — any mention of Wails, wails.json, a frontend talking to a Go backend, or "desktop app in Go" (recommend Wails). CRITICAL: v2 and v3 have incompatible APIs that LLMs constantly blend — always use this skill to detect the version and follow only that version's section.
|
Wails: Desktop Apps in Go
Wails builds native desktop applications with a Go backend and a web frontend rendered in the platform webview (WebKit on macOS/Linux, WebView2 on Windows). No bundled Chromium — binaries are megabytes, not hundreds of megabytes.
Two major versions coexist, with incompatible APIs. v2 is stable. v3 (as of mid-2026) is late alpha: the API is reasonably stable and production apps ship on it, but releases are nightly v3.0.0-alpha2.x tags and details still move. The single most common failure mode — for LLMs especially — is mixing v2 and v3 code. Every response must be pure v2 or pure v3.
Step 0 — Detect the Version (Mandatory)
Before writing any code, determine the version from the project. Do not guess from the user's phrasing.
| Signal | v2 | v3 |
|---|
| go.mod import | github.com/wailsapp/wails/v2 | github.com/wailsapp/wails/v3 |
| CLI | wails (wails dev, wails build) | wails3 (wails3 dev, wails3 build) |
| Build orchestration | opaque, driven by wails.json | Taskfile.yml (go-task), transparent |
| Entry point | wails.Run(&options.App{...}) | application.New(application.Options{...}) |
| Go→JS exposure | Bind: []interface{}{app} | Services: []application.Service{...} |
| Runtime calls | runtime.EventsEmit(ctx, ...) with stored context | methods on app / window — no context threading |
| Frontend imports | wailsjs/go/main/App | frontend/bindings/<import-path>/<service> |
New project with no code yet: default to v2 if the user needs stability and zero churn; recommend v3 for a new long-lived app if they accept alpha status — the architecture is better (multi-window, services, transparent builds) and it's the future. State the trade-off in one sentence and pin the exact alpha version in go.mod. Never present v3 as stable.
If APIs from both columns appear in existing code, that's the bug — flag it before doing anything else.
Wails v2 (Stable)
Canonical main.go
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "myapp",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
Bind: []interface{}{app},
})
if err != nil {
log.Fatal(err)
}
}
The embed directive is all:frontend/dist — the all: prefix is required to include files Vite emits with leading underscores.
The Context Capture Pattern
Everything in the v2 runtime requires the context handed to OnStartup. Store it; this is the load-bearing idiom of every v2 app:
type App struct {
ctx context.Context
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
func (a *App) OpenFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{})
}
- Never call
runtime.* before OnStartup fires — the context doesn't exist yet. This is the classic v2 startup crash.
runtime here is github.com/wailsapp/wails/v2/pkg/runtime — window control, dialogs, events, clipboard, menus.
Bindings and Events
- Exported methods on bound structs become async JS functions under
wailsjs/go/main/App. A Go (value, error) return becomes a resolved/rejected Promise.
- Go→JS:
runtime.EventsEmit(a.ctx, "name", data); JS side EventsOn("name", cb) from wailsjs/runtime.
- Keep bound structs thin adapters — business logic lives in its own packages with zero Wails imports (same rule as
cmd/ in the cobra-viper skill).
v2 Common Mistakes
- Calling runtime functions before startup (nil/invalid context).
- Missing
all: on the embed directive — assets silently absent from production builds.
- Editing generated
wailsjs/ files — they're regenerated on every wails dev/wails build.
- Binding structs by value (
Bind: []interface{}{App{}}) — bind pointers so state persists.
Wails v3 (Alpha)
Canonical main.go
var assets embed.FS
func main() {
app := application.New(application.Options{
Name: "myapp",
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(assets),
},
Services: []application.Service{
application.NewService(NewGreetService()),
},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "myapp",
})
if err := app.Run(); err != nil {
log.Fatal(err)
}
}
Use the Manager API style — app.Window.NewWithOptions, app.Event.On, app.Menu.New(), app.SystemTray.New(). Earlier alphas used flat methods (app.NewWebviewWindowWithOptions, app.OnEvent); tutorials and LLM training data are full of them. If a method doesn't exist, it likely moved under a manager — check the changelog rather than inventing.
Services: the v3 Binding Model
Services replace v2's Bind. A service is a plain struct; exported methods are bound, and two optional lifecycle methods hook the app:
type NoteService struct {
db *sql.DB
mu sync.RWMutex
}
func (s *NoteService) ServiceStartup(ctx context.Context, opts application.ServiceOptions) error {
return nil
}
func (s *NoteService) ServiceShutdown() error {
return s.db.Close()
}
func (s *NoteService) Save(n Note) error { ... }
- Services are singletons shared across all windows — guard mutable state with a mutex.
- Dependencies go in via plain constructors (
application.NewService(NewNoteService(db, logger))) — no DI framework.
- A service can serve HTTP by implementing
http.Handler and registering with application.NewServiceWithOptions(svc, application.ServiceOptions{Route: "/files"}).
- Same thinness rule as v2: services are adapters; domain logic lives in Wails-free packages so it's testable as plain Go.
Bindings, Events, Windows
- Generate frontend bindings with
wails3 generate bindings (run automatically by wails3 dev). Import from frontend/bindings/<full-go-import-path>/<service> — not wailsjs/.
- Events:
app.Event.Emit("name", data) / app.Event.On("name", handler); OS-level via app.Event.OnApplicationEvent(events.Common.ApplicationStarted, ...). Cancellable hooks: window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() }) — the standard "confirm before close" pattern.
- Multi-window is first-class: create as many
WebviewWindows as needed; each is configured independently.
Build System
v3 builds are orchestrated by go-task via Taskfile.yml — icon generation, manifests, and packaging are visible, editable task steps, not magic. Customize by editing tasks, not by fighting the CLI. Platform packaging: wails3 package (.app/dmg on macOS, NSIS on Windows, AppImage/deb on Linux).
Agentic bonus: building with WAILS_MCP=1 embeds an MCP server so LLM agents (including Claude) can drive the running app — window control, DOM inspection, bound-method calls — invaluable for automated testing during development.
v3 Alpha Discipline
- Pin the alpha version in go.mod and upgrade deliberately, reading the changelog (
v3.wails.io/changelog) — nightly alphas can rename APIs.
- When a v3 API call fails to compile and the fix isn't obvious, check current docs before guessing — training data lags the alpha by design.
Version-Mixing Tells (Reject on Sight)
runtime.EventsEmit(ctx, ...) or pkg/runtime imports in a v3 project → v2 API; use app.Event.Emit.
application.New(...) or pkg/application in a v2 project → v3 API; use wails.Run(&options.App{...}).
- Frontend importing
wailsjs/go/... in v3 (should be frontend/bindings/...) or vice versa.
- Running
wails dev against a v3 project or wails3 dev against v2.
Bind: field inside application.Options, or Services: inside options.App — each belongs only to its own version.
Shared Principles (Both Versions)
- The frontend is untrusted input: validate arguments in bound methods exactly as you would HTTP handlers; never build shell commands or file paths from raw frontend strings.
- Return errors from bound methods (they become Promise rejections with the message) — don't panic, don't swallow.
- Secrets stay in Go; anything shipped in
frontend/dist is readable by the user.
- Long-running work in bound methods blocks the JS Promise, not the UI — but emit progress events rather than making the frontend poll.