| Requirement | Purpose |
|---|---|
| Go 1.26+ | Language runtime |
| Task | Build system (go install github.com/go-task/task/v3/cmd/task@latest) |
GitHub CLI (gh) |
Downloads go-microvm runtime from GitHub Releases |
| golangci-lint | Linting |
| goimports | Import formatting |
| KVM access | Linux: ensure /dev/kvm is accessible to your user |
For the build-dev-system target (builds go-microvm-runner from source instead of downloading):
| Requirement | Purpose |
|---|---|
| libkrun-devel | VM hypervisor (Linux: dnf install libkrun-devel, macOS: brew install libkrun) |
waggle/
├── cmd/waggle/main.go # Entry point, DI wiring, signal handling
├── pkg/
│ ├── domain/ # Pure business types (no external deps)
│ │ ├── environment/ # Aggregate root, state machine, Runtime enum
│ │ ├── execution/ # Executor interface, ExecResult
│ │ └── filesystem/ # FileSystem interface, FileInfo
│ ├── service/ # Application services (orchestration)
│ │ ├── environment.go # Create, Destroy, List, Get, Touch
│ │ ├── execution.go # Execute code, InstallPackages
│ │ └── filesystem.go # WriteFile, ReadFile, ListFiles
│ ├── infra/ # Infrastructure adapters
│ │ ├── vm/ # MicroVMProvider, PortAllocator
│ │ ├── ssh/ # SSHExecutor, SSHFilesystem
│ │ └── store/ # InMemoryStore
│ ├── mcp/ # MCP tool definitions + handlers
│ ├── config/ # Configuration (env vars)
│ └── cleanup/ # Background reaper
├── docs/ # Architecture and development docs
├── Taskfile.yaml # Build system
├── CLAUDE.md # AI assistant instructions
└── go.mod # Go module
# Build (automatically downloads go-microvm runtime + firmware from GitHub Releases)
task build
# Run tests
task test
# Full CI check
task verify| Command | Description |
|---|---|
task build |
Build the waggle binary to bin/ (CGO_ENABLED=0) |
task test |
Run all tests with -race detector |
task lint |
Run golangci-lint |
task lint-fix |
Auto-fix lint issues |
task fmt |
Run go fmt + goimports |
task verify |
Format + lint + test (the full CI gate) |
task tidy |
go mod tidy |
task gen |
go generate ./... (mock generation) |
task clean |
Remove bin/ and coverage files |
task run |
Build and run the server |
task version |
Print version info |
task test-coverage |
Generate HTML coverage report |
go test -v -race -run TestEnvironmentServiceCreate ./pkg/service/
go test -v -race -run TestPortAllocatorConcurrent ./pkg/infra/vm/- Add the tool constant in
pkg/mcp/tools.go - Add the tool definition function (using mcp-go's fluent builder)
- Add it to the
Tools()slice - Add the handler method on
ToolHandlerinpkg/mcp/handlers.go - Register it in the switch statement in
pkg/mcp/server.go - Add tests in
tools_test.goandhandlers_test.go
- Add the constant to
Runtimeinpkg/domain/environment/runtime.go - Add it to
ValidRuntimesslice - Add cases in
Validate(),FileExtension(),ExecCommand(),PackageInstallCommand() - Add tests in
runtime_test.go - Configure the OCI image via
WAGGLE_IMAGE_<RUNTIME>(or add a default image in config)
All tests should be table-driven with parallel execution:
func TestSomething(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// ...
}{
{name: "case 1", /* ... */},
{name: "case 2", /* ... */},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// test body
})
}
}Service tests use fake implementations defined in the test files:
fakeProvider— implementsvm.VMProvider(in-memory VM tracking)fakeExecutor— implementsexecution.Executor(records last command)fakeFS— implementsfilesystem.FileSystem(in-memory file map)
The PortAllocator is real but with SetListenCheck() to skip port probing:
portAlloc := vm.NewPortAllocator(20000, 20100)
portAlloc.SetListenCheck(func(_ uint16) error { return nil })Typed sentinel errors in pkg/domain/environment/errors.go:
ErrNotFound— environment doesn't existErrNotRunning— operation requires running environmentErrMaxEnvironments— capacity exceededErrInvalidRuntime— unsupported runtime stringErrInvalidTransition— illegal state machine transition
Two distinct paths in tool handlers:
- User-facing (validation, not-found):
return mcp.NewToolResultError("message"), nil - Internal (server bug):
return nil, err
This distinction matters for the MCP protocol — user-facing errors are tool results (shown to the user), while internal errors are protocol-level failures.
Always wrap with context: fmt.Errorf("create VM: %w", err)
- SPDX headers on every
.goand.yamlfile log/slogfor logging (neverfmt.Printlnorlog.Printf)- Every package has a
doc.go - Domain interfaces in
pkg/domain/, implementations inpkg/infra/ - Imports ordered: stdlib, external, internal (enforced by gci in
.golangci.yml)
# Optional: override runtime images
# export WAGGLE_IMAGE_PYTHON=ghcr.io/stacklok/waggle/python:latest
# export WAGGLE_IMAGE_NODE=ghcr.io/stacklok/waggle/node:latest
# export WAGGLE_IMAGE_SHELL=ghcr.io/stacklok/waggle/shell:latest
# Optional: customize settings
export WAGGLE_LISTEN_ADDR=127.0.0.1:9090
export WAGGLE_MAX_ENVIRONMENTS=5
export WAGGLE_DEFAULT_TIMEOUT_MIN=60
# Run
task runThe server binds to 127.0.0.1:8080 by default with the MCP endpoint at /mcp.
Use the Taskfile targets for local verification:
task build
task test
task lint
task verifySee docs/CUSTOM_IMAGES.md for the checklist and troubleshooting tips when using your own runtime images.
# Initialize MCP session
curl -s -X POST http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "curl-test", "version": "0.1"}
}
}' | jq .
# List tools (after initialization)
curl -s -X POST http://127.0.0.1:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-11-25" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}' | jq .