Imported from spf13/go-skills (
wails/SKILL.md). Install upstream withnpx skills add spf13/go-skills --skill wails. Copyright stays with the author.
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
//go:embed all:frontend/dist
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 // required for all runtime calls
}
func (a *App) OpenFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{})
}
- Never call
runtime.*beforeOnStartupfires — the context doesn't exist yet. This is the classic v2 startup crash. runtimehere isgithub.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 sideEventsOn("name", cb)fromwailsjs/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 everywails dev/wails build. - Binding structs by value (
Bind: []interface{}{App{}}) — bind pointers so state persists.
Wails v3 (Alpha)
Canonical main.go
//go:embed all:frontend/dist
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 { // blocks; must run on the main goroutine
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 {
// open resources, run migrations, start background goroutines governed by ctx
return nil // returning error aborts app startup
}
func (s *NoteService) ServiceShutdown() error {
return s.db.Close() // services shut down in reverse registration order
}
func (s *NoteService) Save(n Note) error { ... } // callable from JS
- 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.Handlerand registering withapplication.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 bywails3 dev). Import fromfrontend/bindings/<full-go-import-path>/<service>— notwailsjs/. - Events:
app.Event.Emit("name", data)/app.Event.On("name", handler); OS-level viaapp.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, ...)orpkg/runtimeimports in a v3 project → v2 API; useapp.Event.Emit.application.New(...)orpkg/applicationin a v2 project → v3 API; usewails.Run(&options.App{...}).- Frontend importing
wailsjs/go/...in v3 (should befrontend/bindings/...) or vice versa. - Running
wails devagainst a v3 project orwails3 devagainst v2. Bind:field insideapplication.Options, orServices:insideoptions.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/distis 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.