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) or image: (OCI container), never both. oneshot: true cannot have ready:. workdir is 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:

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 M depends on X, every other service that also depends on X is automatically rewritten to depend on M instead.

Readiness probes

Exactly one probe type per ready: block:

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:

  1. ports: — publishes a host port that forwards into the service. ports: ["5273:5273"] maps host 5273 → container 5273.
  2. 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 for vite, next dev, and many dev servers) is unreachable: the probe never goes ready and the browser gets ERR_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_REFUSED and the service log shows Local: http://localhost:<port> with no Network: line → the dev server is loopback-only. Add --host 0.0.0.0 (and ports:).

oneshot

A service marked oneshot: true is expected to run to completion. Exit 0 = "ready", exit ≠ 0 = stack failure. Typical use: schema migrations.

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

FieldTypeDescription
version (required)1Schema version.
kind (required)"devstack"Only valid value for .sklp/dev.yaml.
services (required)mapService name → service definition.
namestringHuman label.
extendsstring or [string]Inherits from sibling YAML.
envmapPipeline-level env.
env_files[string]Dotenv files.
toolsmapTool version pins.

Service

FieldTypeDescription
run (XOR image)stringShell command.
image (XOR run)stringOCI container reference.
workdirstringRepo-root-relative.
envmapPer-service env overlay.
depends_on[string]Services that must be ready.
oneshotboolRun-to-completion. Mutually exclusive with ready:.
optionalboolReserved for future --with flag.
readyobjectReadiness probe.
ports[string]image only.
volumes[string]image only.
command[string]image only.

Ready

FieldTypeDescription
httpURLGET polled; any 2xx = ready.
tcphost:portDialTimeout.
logregexLine-by-line match.
filepathPolled until exists.
timeoutdurationGo 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"
            }
          }
        }
      }
    }
  ]
}