Working name, not final ("OpenShip" was taken).
Self-hosted deploy platform. Alternative to Coolify, Dokploy and OpenDeploy. Differentiator: UI/UX and deploy DX.
- Canvas first. Railway-inspired. Projects are a canvas by default.
- Multi-server by default. Scaling past one machine must be effortless. Single-node clusters stay first-class.
- Almost zero networking for the user. We handle it, except opening ports on the control plane:
- Tailscale for user containers and control-plane/worker comms.
- Public traffic via
keel-proxy(Caddy + caddy-l4,keel proxyininternal/proxy) on the control plane only, never a per-worker proxy: HTTPS on 80/443 with automatic certificates (sslip.io default domain or the user's own), raw TCP on any port but 80/443 and UDP on any port, all configured bykeel servethrough Caddy's admin API. The user opens 80/443 and the TCP/UDP ports they expose. Cloudflare Quick Tunnel was dropped 2026-10-06, Tailscale Funnel 2026-10-01. Seedocs/networking.md.
Each rule names what enforces it. When an agent gets corrected for something new, add it here at the strongest level that works: impossible in code, then a check, then a test, then this table (the /correct skill).
| Rule | Enforced by |
|---|---|
No comments in Go, none at all: say it with a name, a type or a test; design reasons go in docs/ or the commit message. Only //go: directives and generated files are exempt. |
tools/nocomments in CI and make test (parses every Go file) |
No new comments in TypeScript (@ts-expect-error and lint-disable directives are exempt). Existing ones are debt to remove. |
scripts/check-comments.sh in CI: per-file counts in .comments-baseline only go down |
Destructure objects: const { sink } = data, not const sink = data.sink. |
oxlint prefer-destructuring |
| Effects only sync with outside systems. Derive values during render, reset state by remounting, change state in event handlers. | oxlint react/set-state-in-effect, react/no-deriving-state-in-effects, react/exhaustive-deps |
No ?? or ?. on a value that can't be missing. Make the type true at the source instead: the API never sends null for an array, and index access is T | undefined (noUncheckedIndexedAccess). |
oxlint typescript/no-unnecessary-condition (--type-aware) |
Read the fields a component needs with select and destructure: const { data: ip } = useGetControlPlane({ query: { select: (c) => c.publicIp } }), not useGetControlPlane().data?.publicIp. |
review |
Forms are native <form>s. Inputs carry their constraints (type, min, max, required), formValues plus the generated zod schema build the body, and the generated mutation hook's isPending is the busy state. No hand-written parse helpers or busy flags. |
review |
No nested ternaries, and no ternary where a lookup table typed by the union says it (stateTone[e.state], firstProtocol[node.type]). |
oxlint no-nested-ternary |
No in checks. Narrow with a discriminant field, instanceof, or a type that already says which shape it is. |
oxlint keel/no-in (tools/oxlint/keel.js) |
Data a view can't render without comes from the generated …Suspense hook and is destructured (const { data: { variables } } = useListVariablesSuspense(…)); the panel's Suspense and ErrorBoundary own loading and failure. |
review |
Domain rules live in Go, not in components: the API returns what the UI shows (suggestions, a key's suggested name as) instead of the dashboard rebuilding it from lists with Sets and flatMaps. |
review |
A module-level constant earns its place: used in two or more places, or a regex or table built once. A value used once goes inline, where its key already names it (refetchInterval: 3_000). |
review |
| A file exports one component. A small component used only by that file may stay next to it; anything else gets its own file. | review |
Generated code is never edited and lives apart: apps/web/src/gen (Kubb's api/, TanStack Router's route-tree.ts, Varlock's env.ts; gitignored) and internal/gen (sqlc's sqlc/, Code generated header). |
CI openapi.json is current; bun run api:generate on dev, build, check-types |
No backfills, data migrations or legacy cleanup in the codebase: no start-up pass that fixes old rows, no "remove the old X" job, no compatibility with an earlier version's formats, names or paths. New rows get correct data when they are created. A one-off backfill runs locally against the target; one that truly must ship lives in its own package or directory, so removing it is one rm. Schema migrations (SQL files) are fine. |
review |
install.shis the product's front door: one idempotent command, re-run = upgrade,KEEL_JSON=1for agents. Its contract (options, output, verification) is documented inREADME.md; keep the two in sync.- One binary,
keel(cmd/keel,internal/,docs/go/ARCHITECTURE.md), one imageghcr.io/thallesp/keel(rootDockerfile, built by.github/workflows/images.yml). Control plane =deploy/compose.yml:keel serve(API, embedded dashboard, SQLite in thekeel-datavolume, Swarm driver; manages thekeel-agentglobal service itself) andkeel proxy(the public edge, its own container, never the Docker socket), both from that image.ci.ymlruns Go and web checks andinstall.shend to end; anything that changes install behaviour must keep it green. - The dashboard and the API share one origin, so the dashboard needs no runtime config. Never bake deployment URLs into the web build.
- The CLI is the same binary (
internal/cli: one file per noun;internal/cli/clientis its HTTP client), for agents first (Railway CLI is the benchmark). Thin client over Keel's HTTP API (/api,openapi.json, session token as bearer, device login forkeel login); it finds an install through the dashboard URL (/api/meta), so keep that response's shape. - Its output contract (JSON envelope like
install.sh, error codes, exit codes) is indocs/cli.md. Fields and codes are only ever added. - Error codes come from the server: every API error is
application/problem+jsonwith acodefrom the CLI's vocabulary (domain.Code*); the CLI uses it as is and only computes thefix(withFixininternal/cli/client/client.go). A new domain code is a new CLI code: add it tointernal/cli/outputanddocs/cli.md. Messages stay the Convex-era strings verbatim.
docs/canvas.md— v1 canvas UI spec (layout, nodes, edges, bottom panel, ship flow, tokens, React Flow mapping, where to start). Read before any UI work.docs/agents.md— agents-first layer on top of v1. Do not start before canvas v1 works.docs/workers.md— worker layer: Docker Swarm as reconciler,keel serveas control plane, thekeel-agentglobal service on every node, node join flow, schema, apply/observe jobs. Read before any Swarm or node-join code (internal/app/{swarm,observe,reconcile,events}.go,internal/adapters/swarm,internal/agent).docs/volumes.md— persistent data: pin + backup, no distributed storage, two-pass rsync migration between nodes, backup by kind, schema. Read before anything that mounts a volume, schedules a backup, or moves a service between servers.docs/logs.md— log sinks and providers: per-organization sink (Axiom today, Docker default), the agent ships lines,TailNodeLogsdispatches on provider, event shape contract, how to add ClickHouse; OpenTelemetry traces in the sink's second dataset (root span = request), joined to log lines by the trace id the line names; spans come in through Keel's OTLP relay from services with tracing on and fromkeel run, and apps get instrumented by pasting Keel's agent prompt, not by a Keel SDK. Read before touchinginternal/app/{logs,logs_docker,sinks,axiom,traces,traces_parse,otlp,tracing,tracing_prompt,observability}.go,internal/adapters/axiom,components/canvas/observability, orinternal/agent/{logs,sink,axiom}.go.docs/networking.md— Tailscale mesh, public ingress via keel-proxy (keel proxy,internal/proxy; why Caddy + caddy-l4 over Traefik/HAProxy/Envoy, host-namespace listeners from an overlay container, endpoints, sync, certificate reports), why Cloudflare Tunnel and Funnel were dropped. Read before any ingress, domain, or auth code.- Mockups: Paper file "Ship".