All AI agents must conform to AI_POLICY.md
Arcane is a modern Docker management UI with a Go backend (Huma v2 API), SvelteKit frontend (Svelte 5), and an optional headless agent. Three Go modules unified via go.work: backend/, cli/, types/.
./scripts/development/dev.sh start # Start Docker-based dev environment (hot reload)
./scripts/development/dev.sh stop|restart|rebuild|clean|logs- Frontend: http://localhost:3000 (Vite HMR)
- Backend: http://localhost:3552 (Air hot reload)
internal/
├── bootstrap/ # App initialization & DI wiring — START HERE for understanding how services connect
├── huma/handlers/ # HTTP handlers (Huma v2) — thin wrappers that call services
├── services/ # Business logic — *_service.go files contain all domain logic
├── models/ # GORM database models (include BaseModel for UUID, timestamps)
├── config/ # Environment configuration
└── middleware/ # Auth, logging, rate limiting
Key patterns:
- Handlers are thin: extract user from context, call service, return response
- Services receive dependencies via constructor injection (see bootstrap.go)
- Use
slogfor structured logging with context - Error wrapping:
fmt.Errorf("context: %w", err)
routes/(app)/ # Main app pages (dashboard, containers, images, etc.)
routes/(auth)/ # Auth pages
lib/components/ # Reusable Svelte components (shadcn-svelte based)
lib/services/ # API service classes extending BaseAPIService
lib/stores/ # Svelte stores (*.store.svelte files use runes)
lib/types/ # TypeScript types
Domain types shared between backend and CLI. Each domain has its own package (e.g., types/container/, types/image/).
<!-- Props: use $props() -->
let { prop1, prop2 }: { prop1: string; prop2?: number } = $props();
<!-- State: use $state() -->
let count = $state(0);
<!-- Derived values: use $derived() or $derived.by() -->
let doubled = $derived(count * 2);
let computed = $derived.by(() => complexCalculation());
<!-- Side effects: use $effect() -->
$effect(() => { /* runs when dependencies change */ });NEVER use: export let, on:click (use onclick), $:, $$props, $$restProps, slot syntax
Example component: job-card.svelte
Frontend services extend BaseAPIService and use environmentStore for multi-environment support:
export class ContainerService extends BaseAPIService {
async getContainers(options?: SearchPaginationSortRequest) {
const envId = await environmentStore.getCurrentEnvironmentId();
const params = transformPaginationParams(options);
return this.api.get(`/environments/${envId}/containers`, { params });
}
}
export const containerService = new ContainerService();Handlers use typed input/output structs with struct tags for validation:
type ListContainersInput struct {
EnvironmentID string `path:"id" doc:"Environment ID"`
Search string `query:"search" doc:"Search query"`
Limit int `query:"limit" default:"20" doc:"Limit"`
}Register handlers in backend/internal/huma/handlers/.
# Backend unit tests
cd backend && go test ./...
# E2E tests (Playwright)
just test e2e
# Frontend type checking
just lint frontendBackend tests use in-memory SQLite and testify. See auth_service_test.go for patterns.
Bad: Handler contains business logic
func (h *ContainerHandler) RestartContainer(ctx context.Context, input *RestartInput) (*RestartOutput, error) {
container, err := h.dockerClient.ContainerInspect(ctx, input.ID)
if container.State.Running {
h.dockerClient.ContainerStop(ctx, input.ID, nil)
}
return h.dockerClient.ContainerStart(ctx, input.ID, nil)
}Good: Handler calls service
func (h *ContainerHandler) RestartContainer(ctx context.Context, input *RestartInput) (*RestartOutput, error) {
err := h.containerService.Restart(ctx, input.EnvironmentID, input.ID)
if err != nil {
return nil, fmt.Errorf("failed to restart container: %w", err)
}
return &RestartOutput{Success: true}, nil
}Bad: Using deprecated Svelte 4 patterns
<script>
export let name;
$: greeting = `Hello ${name}`;
</script>
<button on:click={handleClick}>Click</button>Good: Using Svelte 5 runes
<script lang="ts">
let { name }: { name: string } = $props();
let greeting = $derived(`Hello ${name}`);
</script>
<button onclick={handleClick}>Click</button>Bad: Hardcoded API path without environment
async getContainers() {
return this.api.get('/containers');
}Good: Include environment ID in path
async getContainers() {
const envId = await environmentStore.getCurrentEnvironmentId();
return this.api.get(`/environments/${envId}/containers`);
}Bad: Model without standard fields
type Stack struct {
ID string `json:"id"`
Name string `json:"name"`
}Good: Model with BaseModel
type Stack struct {
models.BaseModel
Name string `json:"name" gorm:"column:name"`
}
func (Stack) TableName() string { return "stacks" }Bad: Untyped data
function processContainer(data: any) {
return data.name;
}Good: Properly typed
import type { Container } from '$lib/types';
function processContainer(data: Container): string {
return data.name;
}- Use generic authentication (bearer tokens, basic auth)
- Support multiple providers (Docker Hub, GHCR, custom OCI)
- Use case-insensitive header checking
Arcane supports managing multiple Docker environments (local + remote agents). The frontend uses environmentStore to track the active environment:
// LOCAL_DOCKER_ENVIRONMENT_ID = '0' is the local Docker socket
const envId = await environmentStore.getCurrentEnvironmentId();
// All API calls include environment ID in the path
this.api.get(`/environments/${envId}/containers`);Key patterns:
- Environment ID
"0"= local Docker connection - Remote environments connect via agents (standard or edge)
environmentStore.readyis a Promise — await before accessing environment-specific data- When environment changes, resource detail pages redirect to list pages (resources don't exist across environments)
See environment.store.svelte.ts for implementation.
Jobs use cron-based scheduling via robfig/cron/v3. To add a new job:
- Implement the
Jobinterface inbackend/pkg/scheduler/:
type Job interface {
Name() string // Unique job identifier
Schedule(ctx context.Context) string // Cron expression (6-field with seconds)
Run(ctx context.Context) // Job execution logic
}- Create job with service dependencies:
type MyJob struct {
myService *services.MyService
settingsService *services.SettingsService
}
func NewMyJob(myService *services.MyService, settings *services.SettingsService) *MyJob {
return &MyJob{myService: myService, settingsService: settings}
}
func (j *MyJob) Schedule(ctx context.Context) string {
return j.settingsService.GetStringSetting(ctx, "myJobInterval", "0 0 * * * *") // hourly default
}- Register in jobs_bootstrap.go:
myJob := pkg_scheduler.NewMyJob(appServices.MyService, appServices.Settings)
newScheduler.RegisterJob(myJob)Note: Cron uses 6 fields (with seconds): "0 0 * * * *" = every hour at :00:00
All models embed BaseModel for UUID primary key and timestamps:
type MyModel struct {
models.BaseModel // ID, CreatedAt, UpdatedAt
Name string `json:"name" gorm:"column:name" sortable:"true"`
ForeignID string `json:"foreignId" gorm:"column:foreign_id"`
Related *OtherModel `json:"related,omitempty" gorm:"foreignKey:ForeignID"`
}
func (MyModel) TableName() string { return "my_models" }Always use Preload for eager loading relationships:
// Single preload
s.db.WithContext(ctx).Preload("Registry").Where("id = ?", id).First(&template)
// Multiple preloads
s.db.WithContext(ctx).
Preload("Repository").
Preload("Project").
Where("id = ?", id).First(&sync)Use models.JSON for arbitrary JSON fields and models.StringSlice for string arrays — both implement driver.Valuer and sql.Scanner.
Edge agents connect to a central Arcane manager via WebSocket tunnel, allowing management of Docker hosts behind NAT/firewalls.
Architecture:
- Manager: Receives tunnel connections, proxies HTTP requests over WebSocket
- Agent: Connects outbound to manager, executes requests locally
Configuration (agent side):
ARCANE_EDGE_AGENT=true
ARCANE_MANAGER_API_URL=https://manager.example.com
ARCANE_AGENT_TOKEN=<api-key>Message types (see tunnel.go):
request/response: HTTP request/response proxyingheartbeat/heartbeat_ack: Connection keepalivews_start/ws_data/ws_close: WebSocket streaming (logs, stats)
When implementing agent features:
- Check
cfg.AgentModeto skip manager-only logic (e.g., environment health checks) - Agent auto-pairs with manager on startup if token is configured
- Edge connections are stateless — each request is independent
If you're an AI coding agent (like Claude Code, GitHub Copilot, Cursor, or similar) assisting a human developer:
- Must read: AI_POLICY.md — Disclosure requirements and quality standards
- Must follow: All coding patterns in this document
- Must ensure: Human has tested the changes locally
When working with Arcane:
❌ Don't use Svelte 4 syntax: This project uses Svelte 5 exclusively. No export let, no on:click, no $: reactive statements.
❌ Don't put business logic in handlers: Handlers should be thin wrappers that call services. Check backend/internal/services/ for patterns.
❌ Don't ignore multi-environment patterns: All API endpoints must include environment ID. Check environmentStore usage in frontend services.
❌ Don't skip BaseModel: All database models must embed models.BaseModel for UUID and timestamps.
❌ Don't ignore existing patterns: Before writing new code, search for similar functionality:
# Find existing patterns
git grep "func.*Service" backend/internal/services/
git grep "extends BaseAPIService" frontend/src/lib/services/- Start with the issue: Read the full GitHub issue and understand the user's actual problem
- Find existing patterns: Search for similar code in the same package/directory
- Follow the pattern: Match structure, error handling, naming conventions
- Test comprehensively: Run dev environment, verify frontend and backend work
- Explain in human terms: Write PR descriptions that explain WHY, not just WHAT
Before submitting any AI-assisted contribution, ensure:
# 1. Start development environment
./scripts/development/dev.sh start
# 2. Backend tests (if you changed Go code)
just test backend
# 3. Frontend type checking (if you changed frontend code)
just lint frontend
# 4. E2E tests
just test e2e
# 5. Verify hot reload works
# - Frontend: http://localhost:3000
# - Backend: http://localhost:3552If any of these fail, do not submit the PR. Fix the issues first.
## Summary
[One paragraph explaining what this PR does and why]
## Related Issue
Fixes #[issue number]
## Changes
- [Specific change 1 with rationale]
- [Specific change 2 with rationale]
## Testing
- [ ] Dev environment starts successfully
- [ ] Backend tests pass: `cd backend && go test ./...`
- [ ] Frontend type checks pass: `just lint frontend`
- [ ] Manually tested: [describe how]
## AI Tool Used
AI Tool: [e.g., Claude Code, GitHub Copilot, Cursor]
Assistance Level: [Significant/Moderate/Minor]