sklp dev — devstack reference
Configuration for `sklp dev`, the local-stack supervisor that launches your services from `.sklp/dev.yaml` with explicit ordering, readiness probes, and one-shot steps (migrations).
For AI coding agents (Claude Code, Codex, Cursor)
This page is your single source of truth for authoring or editing a
.sklp/dev.yaml. The full JSON Schema (Draft 2020-12) is embedded at the bottom of this page inside a<script type="application/json" id="devstack-schema">tag — fetch this URL once and you have prose + machine schema in the same payload.Hard rules: a service is either
run:(shell) orimage:(OCI container), never both.oneshot: truecannot haveready:.workdiris repo-root relative (use.for root Makefile commands). Never invent ports — read the source or ask the user.
Quick start
Generate a draft automatically:
sklp init -only dev # writes .sklp/dev.yaml (LLM-driven, ~30s)
sklp dev --validate # parses + auto-fixes obvious typos
sklp dev # launches the stack
Minimal mono-app
# yaml-language-server: $schema=https://raw.githubusercontent.com/digstack/skalpai/main/docs/sklp/devstack.schema.json
version: 1
kind: devstack
services:
api:
run: go run .
workdir: apps/core
ready:
http: http://localhost:8080/health
Backend + frontend
version: 1
kind: devstack
env_files: [.env]
services:
api:
run: go run .
workdir: apps/core
ready:
http: http://localhost:4100/health
timeout: 60s
web:
run: pnpm --filter @app/web dev
workdir: apps/web
depends_on: [api]
Full stack with infra and migrations
version: 1
kind: devstack
name: dev
env_files: [.env]
services:
infra:
run: docker compose -f compose.yml up --wait
oneshot: true # `--wait` exits 0 once healthy
migrate:
run: make migrate
workdir: . # root Makefile lives at repo root
oneshot: true
depends_on: [infra]
core:
run: go run .
workdir: apps/core
depends_on: [migrate] # waits for migrate to exit 0
ready:
http: http://localhost:4110/health
timeout: 60s
web:
run: pnpm --filter @app/web dev
workdir: apps/web
depends_on: [core]
Native OCI infra (no docker compose required)
version: 1
kind: devstack
env_files: [.env]
services:
postgres:
image: postgres:16-alpine
ports: ["5432:5432"]
env:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
volumes:
- pg-data:/var/lib/postgresql/data
ready:
tcp: localhost:5432
nats:
image: nats:2.10-alpine
command: ["-js", "-sd", "/data"]
ports: ["4222:4222"]
volumes: [nats-data:/data]
ready:
tcp: localhost:4222
migrate:
run: make migrate
workdir: .
oneshot: true
depends_on: [postgres]
core:
run: go run .
workdir: apps/core
depends_on: [migrate, nats]
ready:
http: http://localhost:4110/health
Concepts
Services
Each entry under services: is either a long-running process or a one-shot step. The map key is the log prefix (e.g. [api ]) and the identifier used in depends_on.
Two service kinds:
- Shell service (
run:) — sklp launchessh -c <run>in a process group. - OCI service (
image:) — sklp drivesdocker/podman/nerdctldirectly (auto-detected). No docker compose required.
run and image are mutually exclusive.
depends_on
Lists services that must reach Ready before this service launches. Cycles and unknown targets are rejected at parse time.
Auto-redirect through oneshot. When a oneshot
Mdepends onX, every other service that also depends onXis automatically rewritten to depend onMinstead.
Readiness probes
Exactly one probe type per ready: block:
http:— URL polled with GET, any 2xx = readytcp:— host:port opened with DialTimeout (matches as soon as the port is bound — usually too early for databases)log:— regex matched against stdout/stderrfile:— path that must exist on disk
Auto-injected default. When a service has at least one dependent and no
ready:block, sklp injects a generic log probe matching "database system is ready to accept connections", "ready for connections", "nats server is ready", etc.
Exposing a service to the host (browser / curl)
In the embedded runtime each service runs in its own network namespace,
reached over its bridge IP — localhost inside a service is private to it. To
open a service at http://localhost:<port> on the host, a run: service needs
two things:
ports:— publishes a host port that forwards into the service.ports: ["5273:5273"]maps host5273→ container5273.- Bind
0.0.0.0, not localhost — the readiness probe and the host ingress reach the service by its bridge IP, so a loopback-only bind (the default forvite,next dev, and many dev servers) is unreachable: the probe never goes ready and the browser getsERR_CONNECTION_REFUSED.
web:
# bind 0.0.0.0 (vite defaults to localhost-only) + publish the port
run: pnpm --filter @app/web exec vite --host 0.0.0.0 --port 5273
ports: ["5273:5273"]
ready:
tcp: "0.0.0.0:5273"
image: services that already listen on 0.0.0.0 (postgres, nats, a Go server
on [::]) only need ports: — no --host flag. Other services reach them by
name on the bridge (postgres:5432); there is no SKLP_PORT_<svc> env.
Symptom cheat-sheet.
localhost:<port>→ERR_CONNECTION_REFUSEDand the service log showsLocal: http://localhost:<port>with noNetwork:line → the dev server is loopback-only. Add--host 0.0.0.0(andports:).
oneshot
A service marked oneshot: true is expected to run to completion. Exit 0 = "ready", exit ≠ 0 = stack failure. Typical use: schema migrations.
- Cannot declare
ready:(exit IS the ready signal). - Dependents launch after the oneshot exits 0.
OCI services
When a service declares image:, sklp launches the container directly. Runtime auto-detection order: docker → podman → nerdctl. Container name convention: sklp-dev-<service>. Network model in v1: --network host.
Schema reference
Root
| Field | Type | Description |
|---|---|---|
version (required) | 1 | Schema version. |
kind (required) | "devstack" | Only valid value for .sklp/dev.yaml. |
services (required) | map | Service name → service definition. |
name | string | Human label. |
extends | string or [string] | Inherits from sibling YAML. |
env | map | Pipeline-level env. |
env_files | [string] | Dotenv files. |
tools | map | Tool version pins. |
Service
| Field | Type | Description |
|---|---|---|
run (XOR image) | string | Shell command. |
image (XOR run) | string | OCI container reference. |
workdir | string | Repo-root-relative. |
env | map | Per-service env overlay. |
depends_on | [string] | Services that must be ready. |
oneshot | bool | Run-to-completion. Mutually exclusive with ready:. |
optional | bool | Reserved for future --with flag. |
ready | object | Readiness probe. |
ports | [string] | image only. |
volumes | [string] | image only. |
command | [string] | image only. |
Ready
| Field | Type | Description |
|---|---|---|
http | URL | GET polled; any 2xx = ready. |
tcp | host:port | DialTimeout. |
log | regex | Line-by-line match. |
file | path | Polled until exists. |
timeout | duration | Go format (30s, 2m). Default 30s. |
Editor setup
Add this first line to .sklp/dev.yaml:
# yaml-language-server: $schema=https://raw.githubusercontent.com/digstack/skalpai/main/docs/sklp/devstack.schema.json
Debugging
[svc] crashed during startup — service exited non-zero before its ready probe matched. Check env, deps, missing node_modules.
[svc] oneshot failed — migration command exited non-zero. The stack tears down.
[svc] ready probe failed — probe timed out. Increase via ready: { timeout: 90s }.
unknown field: dependsOn — did you mean: depends_on? — Strict YAML caught a typo. Run sklp dev --validate to auto-fix.
oneshot services cannot declare ready — Remove the ready: block; exit 0 IS the signal.
Embedded JSON Schema (Draft 2020-12)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/digstack/skalpai/main/docs/sklp/devstack.schema.json",
"title": "sklp dev — devstack",
"description": "Configuration for `sklp dev`, the local-stack supervisor. Declares long-running services (and one-shot steps like migrations), their startup order, readiness probes, and shared environment. Authoritative source of truth: apps/diagnose-engine/runner/schema.go in digstack/skalpai.",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"kind",
"services"
],
"properties": {
"version": {
"type": "integer",
"const": 1,
"description": "Schema version. Always 1 today; bumped only on breaking changes."
},
"kind": {
"type": "string",
"const": "devstack",
"description": "Selects the executor. `devstack` is the only valid value for `.sklp/dev.yaml`. Pipelines (`ci.yaml`, `publish.yaml`) use `kind: pipeline`."
},
"extends": {
"description": "Names other YAML files in the same directory whose content is merged in before this file's own fields. Accepts a single string or a list. Common use: `extends: tasks/_base` to inherit `tools:` pins.",
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
},
"name": {
"type": "string",
"description": "Human label surfaced in the startup banner. Optional."
},
"description": {
"type": "string",
"description": "Free-form description for human readers. Not used by the runtime."
},
"tools": {
"type": "object",
"description": "Tool name → exact version pin (e.g. `go: 1.25.0`). Validated by sklp before launch when present. Usually inherited via `extends: tasks/_base`.",
"additionalProperties": {
"type": "string"
}
},
"env": {
"type": "object",
"description": "Pipeline-level environment variables applied to every service before per-service overrides. Plain strings, no templating.",
"additionalProperties": {
"type": "string"
}
},
"env_files": {
"type": "array",
"description": "Dotenv-style files loaded into every service's environment. Paths are repo-root-relative. Missing files are silently skipped (a `.env.local` that doesn't exist is fine).",
"items": {
"type": "string"
}
},
"services": {
"type": "object",
"description": "Map of service name → service definition. The map key is the log prefix surfaced by the supervisor (e.g. `[api ]`). At least one service is required.",
"minProperties": 1,
"additionalProperties": {
"$ref": "#/$defs/service"
}
}
},
"$defs": {
"service": {
"type": "object",
"additionalProperties": false,
"description": "A service is either a shell command (`run:`) OR a native OCI container (`image:`) — exactly one of the two is required. Image services accept additional fields: `ports`, `volumes`, `command`.",
"properties": {
"run": {
"type": "string",
"description": "Shell command to execute (via `sh -c`). Mutually exclusive with `image:`. Examples: `go run .`, `pnpm --filter @app/web dev`, `docker compose -f compose.yml up --wait`."
},
"image": {
"type": "string",
"description": "OCI container reference. When set, sklp launches the container directly via the local runtime (docker / podman / nerdctl, auto-detected) — no docker compose required. Mutually exclusive with `run:`. Examples: `postgres:16-alpine`, `rg.fr-par.scw.cloud/skaplai/foo:v1.2.3`."
},
"ports": {
"type": "array",
"description": "Port mappings for an `image:` service. Format `host:container` or `:container` (auto host port). Ignored for `run:` services.",
"items": {
"type": "string"
}
},
"volumes": {
"type": "array",
"description": "Volume mounts for an `image:` service. Named (`pg-data:/var/lib/postgresql/data`) or bind (`./data:/data:ro`). Ignored for `run:` services.",
"items": {
"type": "string"
}
},
"command": {
"type": "array",
"description": "Overrides the container image's default CMD. Slice form so arguments with spaces stay intact. Ignored for `run:` services.",
"items": {
"type": "string"
}
},
"workdir": {
"type": "string",
"description": "Directory the command runs in, relative to the repo root. Empty / `.` means the repo root. For `make <target>` commands targeting a root Makefile, set this to `.` even if the service code lives under apps/<svc>. Ignored for `image:` services."
},
"env": {
"type": "object",
"description": "Per-service environment overlay. Layered on top of process env, `env_files`, and pipeline-level `env`.",
"additionalProperties": {
"type": "string"
}
},
"depends_on": {
"type": "array",
"description": "Names of other services that must reach Ready (or, for `oneshot: true`, exit 0) before this service launches. Cycles and unknown targets are rejected at parse time. Auto-redirected: if a oneshot service M depends on X, any other service that depends on X is rewritten to depend on M instead.",
"items": {
"type": "string"
}
},
"optional": {
"type": "boolean",
"description": "Skipped silently by default; opt-in via a future `--with <name>` flag. Use sparingly — most services should always run.",
"default": false
},
"oneshot": {
"type": "boolean",
"description": "Process expected to run to completion (exit 0) BEFORE its dependents start. Typical use: schema migrations (`go run . migrate up`, `prisma migrate deploy`, `alembic upgrade head`). Exit 0 = ready; exit ≠ 0 fails the whole stack. Mutually exclusive with `ready` (exit IS the ready signal).",
"default": false
},
"ready": {
"$ref": "#/$defs/ready"
}
},
"oneOf": [
{
"title": "Shell service",
"required": [
"run"
],
"not": {
"anyOf": [
{
"required": [
"image"
]
},
{
"required": [
"ports"
]
},
{
"required": [
"volumes"
]
},
{
"required": [
"command"
]
}
]
}
},
{
"title": "OCI container service",
"required": [
"image"
],
"not": {
"required": [
"run"
]
}
}
],
"allOf": [
{
"comment": "oneshot and ready are mutually exclusive — exit IS the ready signal for one-shots.",
"if": {
"properties": {
"oneshot": {
"const": true
}
},
"required": [
"oneshot"
]
},
"then": {
"not": {
"required": [
"ready"
]
}
}
}
]
},
"ready": {
"type": "object",
"description": "Readiness probe. Exactly one of `http` / `tcp` / `log` / `file` is honoured (priority order: http, tcp, file, log). When omitted on a service that has dependents, sklp auto-injects a generic log probe matching `database system is ready to accept connections`, `ready for connections`, `nats server is ready`, etc. Override by writing your own block.",
"additionalProperties": false,
"properties": {
"http": {
"type": "string",
"format": "uri",
"description": "URL polled with GET; any 2xx response means ready. Example: `http://localhost:8080/health`."
},
"tcp": {
"type": "string",
"description": "host:port string opened with net.DialTimeout. Example: `localhost:5432`. Note: a TCP probe matches as soon as the port is bound, which can be earlier than the service is functionally ready (Postgres binds the port mid-startup). Prefer `log:` or an HTTP healthcheck for databases."
},
"log": {
"type": "string",
"description": "Regex matched line-by-line against the service's stdout/stderr; first match = ready. Case-insensitive flags allowed (`(?i)...`). Example: `(?i)database system is ready to accept connections`."
},
"file": {
"type": "string",
"description": "Path that must exist on disk (Unix socket, pid file). Polled until present."
},
"timeout": {
"type": "string",
"description": "Maximum wait for the probe to succeed. Go duration format (`30s`, `2m`). Default 30s when omitted; auto-injected probes use 60s.",
"pattern": "^[0-9]+(ns|us|µs|ms|s|m|h)$"
}
},
"oneOf": [
{
"required": [
"http"
]
},
{
"required": [
"tcp"
]
},
{
"required": [
"log"
]
},
{
"required": [
"file"
]
}
]
}
},
"examples": [
{
"title": "Minimal mono-app",
"value": {
"version": 1,
"kind": "devstack",
"services": {
"api": {
"run": "go run .",
"workdir": "apps/core",
"ready": {
"http": "http://localhost:8080/health"
}
}
}
}
},
{
"title": "Backend + frontend",
"value": {
"version": 1,
"kind": "devstack",
"env_files": [
".env"
],
"services": {
"api": {
"run": "go run .",
"workdir": "apps/core",
"ready": {
"http": "http://localhost:4100/health",
"timeout": "60s"
}
},
"web": {
"run": "pnpm --filter @app/web dev",
"workdir": "apps/web",
"depends_on": [
"api"
]
}
}
}
},
{
"title": "Full stack with infra and migrations",
"value": {
"version": 1,
"kind": "devstack",
"name": "dev",
"env_files": [
".env"
],
"services": {
"infra": {
"run": "docker compose -f compose.yml up --wait",
"oneshot": true
},
"migrate": {
"run": "make migrate",
"workdir": ".",
"oneshot": true,
"depends_on": [
"infra"
]
},
"core": {
"run": "go run .",
"workdir": "apps/core",
"depends_on": [
"migrate"
],
"ready": {
"http": "http://localhost:4110/health",
"timeout": "60s"
}
},
"web": {
"run": "pnpm --filter @app/web dev",
"workdir": "apps/web",
"depends_on": [
"core"
]
}
}
}
},
{
"title": "Native OCI infra (no docker compose required)",
"value": {
"version": 1,
"kind": "devstack",
"env_files": [
".env"
],
"services": {
"postgres": {
"image": "postgres:16-alpine",
"ports": [
"5432:5432"
],
"env": {
"POSTGRES_USER": "app",
"POSTGRES_PASSWORD": "app",
"POSTGRES_DB": "app"
},
"volumes": [
"pg-data:/var/lib/postgresql/data"
],
"ready": {
"tcp": "localhost:5432"
}
},
"nats": {
"image": "nats:2.10-alpine",
"command": [
"-js",
"-sd",
"/data"
],
"ports": [
"4222:4222"
],
"volumes": [
"nats-data:/data"
],
"ready": {
"tcp": "localhost:4222"
}
},
"migrate": {
"run": "make migrate",
"workdir": ".",
"oneshot": true,
"depends_on": [
"postgres"
]
},
"core": {
"run": "go run .",
"workdir": "apps/core",
"depends_on": [
"migrate",
"nats"
],
"ready": {
"http": "http://localhost:4110/health"
}
}
}
}
}
]
}