devctl Plugin Authoring Guide (NDJSON Stdio Protocol v2)

A practical playbook for writing, testing, and shipping devctl plugins.

Sections

Terminology & Glossary
📖 Documentation
Navigation
6 sectionsv0.1
📄 devctl Plugin Authoring Guide (NDJSON Stdio Protocol v2) — glaze help plugin-authoring
plugin-authoring

devctl Plugin Authoring Guide (NDJSON Stdio Protocol v2)

A practical playbook for writing, testing, and shipping devctl plugins.

Topicdevctlpluginsprotocolndjsondev-environmenttooling

devctl Plugin Authoring Guide (NDJSON Stdio Protocol v2)

devctl plugins let you take all the “tribal knowledge” of starting a dev environment—ports, env vars, build steps, prerequisites, and how to launch services—and turn it into a small, versioned, testable program. A plugin is just an executable that speaks a tiny NDJSON protocol over stdin/stdout, so devctl can ask it to compute config, validate prerequisites, run build/prepare steps, and produce a launch plan that devctl supervises.

This guide is a playbook, not just a spec. It explains the protocol, shows the mental model devctl uses when it talks to plugins, and gives you copy/paste examples (plus the patterns you’ll want once people actually rely on your plugin every day).

0. Start here (if you're new to devctl)

This document goes deep on the plugin protocol and the patterns that matter once you have multiple plugins, strictness rules, and real users. If you’re new to devctl overall, it’s usually faster to start with the user guide and the scripting guide first, then come back here for the complete protocol and schema details.

devctl help user-guide
devctl help scripting-guide

1. What a devctl plugin is

A devctl plugin is a long-lived child process managed by devctl. devctl starts the process, expects an immediate handshake on stdout, and then drives the dev environment by sending request frames on stdin and reading response/event frames on stdout. Put differently: devctl is the orchestrator, and your plugin is the repo-specific adapter.

At a high level:

  • devctl owns orchestration: ordering, timeouts, strictness, process supervision, state, and log capture.
  • your plugin owns repo-specific logic: config derivation, prerequisite checks, build/prepare steps, and the service plan.
  • the protocol boundary is small on purpose: JSON in, JSON out (so you can write plugins in whatever language your repo already uses).

If you’re replacing a large startdev.sh, a good rule of thumb is: move “policy and knowledge” into the plugin, and keep “running processes and tracking state” in devctl.

2. Quick start: your first plugin in 10 minutes

The quickest path to a useful plugin is to implement config.mutate and launch.plan for one service, then add validate.run so failures are actionable. This section is intentionally “boring”: it gets you to a working devctl up/status/logs/down loop as fast as possible, and you can iterate from there.

Step 1: Create a plugin file

Create plugins/myrepo-plugin.py:

#!/usr/bin/env python3
import json
import sys

def emit(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()

emit({
    "type": "handshake",
    "protocol_version": "v2",
    "plugin_name": "myrepo",
    "capabilities": {"ops": ["config.mutate", "validate.run", "launch.plan"]},
})

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    req = json.loads(line)
    rid = req.get("request_id", "")
    op = req.get("op", "")

    if op == "config.mutate":
        emit({
            "type": "response",
            "request_id": rid,
            "ok": True,
            "output": {"config_patch": {"set": {"services.app.port": 8080}, "unset": []}},
        })
    elif op == "validate.run":
        emit({
            "type": "response",
            "request_id": rid,
            "ok": True,
            "output": {"valid": True, "errors": [], "warnings": []},
        })
    elif op == "launch.plan":
        emit({
            "type": "response",
            "request_id": rid,
            "ok": True,
            "output": {"services": [{"name": "app", "command": ["bash", "-lc", "python3 -m http.server 8080"]}]},
        })
    else:
        emit({
            "type": "response",
            "request_id": rid,
            "ok": False,
            "error": {"code": "E_UNSUPPORTED", "message": f"unsupported op: {op}"},
        })

Make it executable:

chmod +x plugins/myrepo-plugin.py

Step 2: Add a .devctl.yaml

At the repo root:

plugins:
  - id: myrepo
    path: python3
    args:
      - ./plugins/myrepo-plugin.py
    priority: 10

Step 3: Verify handshake and run the pipeline

devctl plugins list
devctl plan
devctl up
devctl status
devctl logs --service app --follow
devctl down

If plugins list works, your handshake is valid and your stdout is clean. If up works, your launch.plan is valid and the supervisor can run the service.

3. The non-negotiable rules

The protocol is strict so devctl can be reliable and debuggable. The goal is that when things go wrong, you can trust the boundary: if devctl says “protocol contamination”, it really means stdout got polluted—not “maybe something else”.

  • stdout is protocol-only NDJSON:
    • one JSON object per line
    • no extra whitespace or banners
    • no progress messages
  • stderr is for humans:
    • print logs, progress, and debug information to stderr
    • devctl captures stderr and prefixes it with the plugin id
  • the first stdout frame must be a handshake.

If you print anything non-JSON to stdout, devctl treats it as protocol contamination and fails the plugin.

4. Lifecycle and pipeline (diagrams)

The plugin lifecycle is a simple handshake + request loop, but it sits inside devctl’s larger pipeline. The most helpful way to design plugins is to think in phases: “what config do we need?”, “what must we build?”, “what must we prepare?”, “is it safe to proceed?”, and “what services should devctl supervise?”

sequenceDiagram
  participant D as devctl
  participant P as plugin (child process)

  D->>P: start process
  P-->>D: stdout: handshake (protocol_version, capabilities)

  D->>P: stdin: request (op=config.mutate)
  P-->>D: stdout: response (config_patch)

  D->>P: stdin: request (op=build.run)
  P-->>D: stdout: response (steps, artifacts)

  D->>P: stdin: request (op=prepare.run)
  P-->>D: stdout: response (steps, artifacts)

  D->>P: stdin: request (op=validate.run)
  P-->>D: stdout: response (valid/errors/warnings)

  D->>P: stdin: request (op=launch.plan)
  P-->>D: stdout: response (services[])

  D->>D: supervise services (process groups, logs, health)
  D->>P: terminate on shutdown

This flowchart is the mental model to preserve as you add capabilities. Most plugin changes should feel like “filling in a box” (implementing one op), not “inventing a new orchestration path”.

flowchart TD
  A[devctl up] --> B[config.mutate]
  B --> C[build.run]
  C --> D[prepare.run]
  D --> E[validate.run]
  E --> F[launch.plan]
  F --> G[Supervisor.Start]
  G --> H[devctl status/logs]
  H --> I[devctl down]

5. Protocol frames: handshake, request, response, event

devctl’s protocol types are defined in Go and mirrored by JSON frames. You don’t need a client library; you just need to produce valid JSON objects with the expected keys and keep stdout clean. When in doubt, copy the JSON shapes in this section exactly and evolve them slowly.

5.1. Handshake (stdout, first frame)

The handshake tells devctl who you are and which operations you support.

{
  "type": "handshake",
  "protocol_version": "v2",
  "plugin_name": "example",
  "capabilities": {
    "ops": ["config.mutate", "validate.run", "build.run", "prepare.run", "launch.plan"],
    "streams": ["logs.follow"],
    "commands": [
      { "name": "db-reset", "help": "Reset local DB" }
    ]
  },
  "declares": {
    "side_effects": "process",
    "idempotent": false
  }
}

Fields:

  • type: must be "handshake".
  • protocol_version: currently "v2".
  • plugin_name: human-readable name.
  • capabilities:
    • ops: list of supported request operations (request.op).
    • streams: optional, for stream-producing ops (see events).
    • commands: optional, for dynamic CLI commands (devctl wires cobra commands directly from this list; no separate discovery request).
  • declares: optional metadata (devctl currently treats this as informational; use it anyway for clarity).

5.2. Request (stdin)

devctl sends request frames on stdin. The important thing to internalize is that devctl may call you with partial input (for example, a subset of requested steps), and it may do so under a deadline. Always validate inputs and handle unknown fields gracefully.

{
  "type": "request",
  "request_id": "plugin-1",
  "op": "config.mutate",
  "ctx": {
    "repo_root": "/abs/path/to/repo",
    "cwd": "",
    "deadline_ms": 30000,
    "dry_run": false
  },
  "input": {
    "config": {}
  }
}

Fields:

  • request_id: unique per call; echo it back in the response.
  • op: operation name, e.g. config.mutate.
  • ctx:
    • repo_root: repo root chosen by the user (--repo-root).
    • deadline_ms: remaining time budget for this operation (best-effort).
    • dry_run: whether devctl intends side effects to be skipped.
  • input: op-specific JSON object; treat it as untrusted input and validate types.

5.3. Response (stdout)

For each request, emit exactly one response on stdout. If your plugin needs to do “background work” (like log following), do that via streams; don’t emit multiple responses for one request.

{
  "type": "response",
  "request_id": "plugin-1",
  "ok": true,
  "output": {}
}

If you can’t handle the request, return ok=false with an error.

{
  "type": "response",
  "request_id": "plugin-1",
  "ok": false,
  "error": { "code": "E_UNSUPPORTED", "message": "unsupported op" }
}

5.4. Event (stdout, streaming)

Some ops return a stream_id in their response output and then emit event frames. A good streaming op behaves like tail -f: it keeps sending log events until it can’t, then ends cleanly.

{ "type": "event", "stream_id": "s1", "event": "log", "level": "info", "message": "hello" }
{ "type": "event", "stream_id": "s1", "event": "end", "ok": true }

Streams are for “follow” style operations where devctl should keep reading until you emit event=end (or devctl terminates the plugin).

6. Implementing common operations

Most real plugins implement some subset of the pipeline ops. devctl will call you only for the ops you declare, and it can merge outputs across multiple plugins in priority order (optionally enforcing strictness rules on collisions). That means you can start small—one plugin, one repo—and still have a path to shared “org-wide defaults” later.

6.1. config.mutate: return a config patch

config.mutate lets the plugin turn repo knowledge into config values. You return a config_patch with set and unset. Keys use dotted paths so you can build nested config incrementally.

{
  "type": "response",
  "request_id": "x",
  "ok": true,
  "output": {
    "config_patch": {
      "set": {
        "env.VITE_BACKEND_URL": "http://localhost:8082",
        "services.backend.port": 8083
      },
      "unset": ["env.SOME_OLD_KEY"]
    }
  }
}

Best practices:

  • treat input.config as the current config and compute a patch from it.
  • keep names stable; changing a key is a breaking change for any scripts that read it.
  • avoid “random” values (like ephemeral ports) unless you also patch the chosen value into config.
  • make the patch idempotent: repeated application should converge.

6.2. validate.run: report pass/fail with actionable errors

Validation should answer: “can we proceed?” and “if not, what should the developer do next?”

{
  "type": "response",
  "request_id": "x",
  "ok": true,
  "output": {
    "valid": false,
    "errors": [
      { "code": "E_MISSING_TOOL", "message": "missing tools: pnpm, docker" }
    ],
    "warnings": []
  }
}

Best practices:

  • keep errors stable and searchable (consistent codes/messages).
  • prefer errors that tell someone exactly what to install or configure.
  • if something is required for some workflows but not others, return it as a warning and document it.

6.3. build.run / prepare.run: steps and artifacts

These ops are for deterministic side-effect steps (compilation, generating files, installing deps). The key design is that these steps are named and selectable, so a developer can rerun just the part they care about (or so CI can run a minimal subset).

{
  "type": "response",
  "request_id": "x",
  "ok": true,
  "output": {
    "steps": [
      { "name": "backend", "ok": true, "duration_ms": 1234 }
    ],
    "artifacts": {
      "backend-bin": "backend/dist/server"
    }
  }
}

Response schema:

FieldTypeRequiredDescription
stepsStepResult[]yesNamed steps that ran.
steps[].namestringyesStep identifier (used for merging across plugins).
steps[].okbooleanyesWhether the step succeeded.
steps[].duration_msnumberoptionalHow long the step took.
artifactsmap<string,string>optionalNamed paths to build outputs (e.g. {"backend-bin": "dist/server"}).

Dry-run behavior:

  • if ctx.dry_run is true, do not perform side effects.
  • still report which commands you would have run (to stderr) and return ok=true if the plan is valid.

Standalone phase commands:

Developers can run these phases directly with devctl build and devctl prepare. devctl first runs config.mutate, then sends the same build.run or prepare.run request used by devctl up. If the user passes --step name, devctl sends those names in input.steps; your plugin should treat an empty list as “run the default/all relevant steps” and a non-empty list as a request to run only those named steps when possible.

devctl build --timeout 10m
devctl build --step backend --step frontend
devctl prepare --step pnpm-install

Progress and logs for long-running steps:

Keep stdout reserved for protocol frames. If a build, install, migration, or code-generation command takes a while, stream human-readable progress to stderr. devctl reads plugin stderr while the request is running, so operators can watch progress without corrupting the NDJSON protocol on stdout.

A safe Python pattern is to pipe subprocess output to stderr while emitting exactly one JSON response on stdout:

def run_streaming(argv, cwd):
    proc = subprocess.Popen(
        argv,
        cwd=cwd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
    )
    for line in proc.stdout:
        sys.stderr.write(line)
        sys.stderr.flush()
    return proc.wait()

Avoid print() for progress unless it writes to stderr. Any non-JSON text on stdout is protocol contamination and will fail the plugin.

6.4. launch.plan: describe services devctl should supervise

The launch plan is what devctl turns into processes, logs, health checks, and devctl status/logs/down. In practice, this is where plugins earn their keep: once the plan is correct, devctl can manage the whole environment consistently.

{
  "type": "response",
  "request_id": "x",
  "ok": true,
  "output": {
    "services": [
      {
        "name": "backend",
        "cwd": "backend",
        "command": ["make", "run"],
        "env": { "PORT": "8083" },
        "health": { "type": "http", "url": "http://127.0.0.1:8083/health", "timeout_ms": 30000 }
      }
    ]
  }
}

Service schema:

FieldTypeRequiredDescription
namestringyesStable identifier for logs, status, and down.
commandstring[]yesargv array. No shell parsing — use ["bash","-lc","..."] if you need a shell.
cwdstringoptionalWorking directory. Relative paths resolved against repo_root.
envmap<string,string>optionalExtra env vars merged with the parent environment.
healthHealthCheckoptionalReadiness check before devctl considers up successful.
health.type"tcp" | "http"yesTCP probe or HTTP GET probe.
health.addressstringfor tcpHost:port to dial (e.g. "127.0.0.1:8080").
health.urlstringfor httpURL to GET. 2xx–4xx counts as healthy.
health.timeout_msnumberoptionalHow long to wait for the service to become ready (default: 30s).

Important: devctl starts services in the order they appear in your launch.plan, then runs health checks in parallel. A service without health is considered immediately ready.

6.5. command.run: plugin-defined CLI commands

Plugins can expose custom commands (e.g., devctl db-reset) without adding Go code to devctl. devctl reads command specs from the handshake and wires cobra subcommands dynamically. This is best used for small “dev chores” that are repo-specific but need to be discoverable and consistent across the team.

In protocol v2, command specs are advertised in the handshake:

{
  "type": "handshake",
  "protocol_version": "v2",
  "plugin_name": "example",
  "capabilities": {
    "ops": ["command.run"],
    "commands": [
      { "name": "db-reset", "help": "Reset local DB" }
    ]
  }
}

devctl invokes the command via command.run:

{
  "type": "request",
  "request_id": "x",
  "op": "command.run",
  "ctx": { "repo_root": "/abs/repo", "deadline_ms": 30000, "dry_run": false },
  "input": { "name": "db-reset", "argv": ["--force"], "config": { "services": {} } }
}

Your response should include an exit code:

{ "type": "response", "request_id": "x", "ok": true, "output": { "exit_code": 0 } }

6.6. Supervision, logs, and process lifecycle

Once launch.plan returns, devctl takes over. Here's what happens to your services:

Process startup:

  • devctl creates a new process group (Setpgid: true) for each service.
  • cwd is resolved relative to repo_root if not absolute.
  • env is merged with devctl's own environment.
  • Logs are written to timestamped files under .devctl/logs/:
    • {name}-{timestamp}.stdout.log
    • {name}-{timestamp}.stderr.log

Health checks:

  • After all services start, devctl waits for each health check to pass.
  • TCP: dials the address every 200ms until success or timeout.
  • HTTP: GETs the URL every 300ms; accepts 2xx–4xx as healthy.
  • If any health check fails, devctl stops all already-started services and reports the error.

Shutdown (devctl down):

  1. devctl sends SIGTERM to the process group.
  2. Waits up to 3 seconds for graceful exit.
  3. Sends SIGKILL if still alive.
  4. Removes .devctl/state.json.

State file: .devctl/state.json tracks PIDs, start times, log paths, and health config. devctl uses it for status, logs, and down. If state exists, a subsequent devctl up will prompt for restart (or use --force).

7. Merge behavior, ordering, and strictness

If you configure multiple plugins, devctl calls them in deterministic order and merges their outputs. This is how you can build a “stack” of plugins (shared org defaults + repo specifics) without forcing every repo to copy/paste the same logic.

  • Ordering:
    • primary key: priority (lower first)
    • tie-break: plugin id (stable)
  • Config merge:
    • each config_patch is applied in order
    • invalid dotted paths and type mismatches are treated as errors
  • Step merge (build.run, prepare.run):
    • steps are merged by name
    • collisions are either errors (strict) or “last wins” (non-strict)
  • Service merge (launch.plan):
    • services are merged by name
    • collisions are either errors (strict) or “last wins” (non-strict)

This is why stable names matter:

  • good: myrepo.backend, myrepo.web, db.reset
  • risky: backend, web (fine in a single-plugin repo, collision-prone in stacks)

8. A minimal Python plugin you can copy/paste

This skeleton is a good starting point for repo-local plugins. It is intentionally small and strict about stdout.

#!/usr/bin/env python3
import json
import sys
import time

def emit(obj):
    sys.stdout.write(json.dumps(obj) + "\n")
    sys.stdout.flush()

def log(msg):
    sys.stderr.write(msg + "\n")
    sys.stderr.flush()

emit({
    "type": "handshake",
    "protocol_version": "v2",
    "plugin_name": "example",
    "capabilities": {"ops": ["config.mutate", "validate.run", "launch.plan"]},
})

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    req = json.loads(line)
    rid = req.get("request_id", "")
    op = req.get("op", "")
    ctx = req.get("ctx", {}) or {}
    inp = req.get("input", {}) or {}

    dry_run = bool(ctx.get("dry_run", False))
    repo_root = ctx.get("repo_root", "")

    if op == "config.mutate":
        emit({"type": "response", "request_id": rid, "ok": True,
              "output": {"config_patch": {"set": {"env.EXAMPLE": "1"}, "unset": []}}})
    elif op == "validate.run":
        emit({"type": "response", "request_id": rid, "ok": True,
              "output": {"valid": True, "errors": [], "warnings": []}})
    elif op == "launch.plan":
        emit({"type": "response", "request_id": rid, "ok": True,
              "output": {"services": [{"name": "sleep", "command": ["bash","-lc","sleep 10"]}]}})
    else:
        emit({"type": "response", "request_id": rid, "ok": False,
              "error": {"code": "E_UNSUPPORTED", "message": f"unsupported op: {op}"}})

9. A minimal Bash plugin skeleton (with jq)

Bash is a perfectly reasonable choice for a plugin when your repo already uses shell scripts heavily. The main pitfall is JSON handling: if you build JSON by hand with string concatenation, you will eventually ship broken escaping and accidentally contaminate stdout. The safest approach is to use jq to both parse requests and construct responses.

This skeleton keeps stdout protocol-clean and sends all human output to stderr.

#!/usr/bin/env bash
set -Eeuo pipefail

log() { echo "[myrepo-plugin] $*" >&2; }

emit() {
  # Always emit exactly one JSON object (NDJSON) to stdout.
  jq -cn ""
}

emit_handshake() {
  emit '{
    type: "handshake",
    protocol_version: "v2",
    plugin_name: "myrepo",
    capabilities: { ops: ["config.mutate", "validate.run", "launch.plan"] }
  }'
}

emit_handshake

while IFS= read -r line; do
  [[ -z "${line}" ]] && continue

  # Parse common fields safely.
  request_id="$(jq -r '.request_id // ""' <<<"$line")"
  op="$(jq -r '.op // ""' <<<"$line")"
  dry_run="$(jq -r '.ctx.dry_run // false' <<<"$line")"
  repo_root="$(jq -r '.ctx.repo_root // ""' <<<"$line")"

  if [[ -z "${request_id}" || -z "${op}" ]]; then
    emit --arg rid "${request_id}" '{
      type: "response",
      request_id: $rid,
      ok: false,
      error: { code: "E_PROTOCOL_INVALID", message: "missing request_id or op" }
    }'
    continue
  fi

  case "${op}" in
    config.mutate)
      emit --arg rid "${request_id}" '{
        type: "response",
        request_id: $rid,
        ok: true,
        output: {
          config_patch: {
            set: { "services.app.port": 8080 },
            unset: []
          }
        }
      }'
      ;;

    validate.run)
      if ! command -v jq >/dev/null 2>&1; then
        emit --arg rid "${request_id}" '{
          type: "response",
          request_id: $rid,
          ok: true,
          output: {
            valid: false,
            errors: [{ code: "E_MISSING_TOOL", message: "missing tools: jq" }],
            warnings: []
          }
        }'
        continue
      fi

      emit --arg rid "${request_id}" '{
        type: "response",
        request_id: $rid,
        ok: true,
        output: { valid: true, errors: [], warnings: [] }
      }'
      ;;

    launch.plan)
      # If you *need* to run shell pipelines, keep them inside the service command,
      # not inside the plugin protocol loop.
      log "launch.plan (repo_root=${repo_root:-} dry_run=${dry_run:-false})"
      emit --arg rid "${request_id}" '{
        type: "response",
        request_id: $rid,
        ok: true,
        output: {
          services: [{
            name: "app",
            command: ["bash", "-lc", "python3 -m http.server 8080"],
            health: { type: "tcp", address: "127.0.0.1:8080", timeout_ms: 30000 }
          }]
        }
      }'
      ;;

    *)
      emit --arg rid "${request_id}" --arg op "${op}" '{
        type: "response",
        request_id: $rid,
        ok: false,
        error: { code: "E_UNSUPPORTED", message: ("unsupported op: " + $op) }
      }'
      ;;
  esac
done

Bash plugin tips:

  • Treat jq as required; if your repo can’t depend on it, write the plugin in Python or Go instead.
  • Never echo JSON by hand; use jq -cn so escaping is correct.
  • Never write anything to stdout except protocol frames; use >&2 for all logs.
  • Prefer to keep heavy process work in devctl supervision (via launch.plan) rather than inside the plugin loop.

10. Wiring your plugin into a repo (.devctl.yaml)

devctl discovers plugins from a config file at the repo root (by default .devctl.yaml). This keeps plugin configuration close to the repo, which is usually what you want for dev environments.

plugins:
  - id: myrepo
    path: python3
    args:
      - ./plugins/myrepo-plugin.py
    env:
      MYREPO_FEATURE_FLAG: "1"
    priority: 10

Then run:

devctl plugins list
devctl plan
devctl up
devctl status
devctl logs --service <name> --follow
devctl down

Profiles depend on stable plugin IDs

Profiles select plugins by id, so treat plugin IDs as stable public names. If you rename a plugin ID, every profile that references it must be updated.

profiles:
  backend:
    plugins: [api, database]

plugins:
  - id: api
    path: python3
    args: [./plugins/api.py]
  - id: database
    path: python3
    args: [./plugins/database.py]

Profile-level env is overlaid onto the selected plugin processes. This makes profile env useful for mode-level behavior such as LOG_LEVEL, feature flags, or local trace settings.

profiles:
  backend:
    plugins: [api]
    env:
      LOG_LEVEL: debug

plugins:
  - id: api
    path: python3
    args: [./plugins/api.py]
    env:
      LOG_LEVEL: info

When backend is active, the api plugin receives LOG_LEVEL=debug.

Planning phases must be safe to re-run

devctl start <service> and devctl restart <service> re-run config.mutate and launch.plan to recover the current service specification without persisting raw service environments in .devctl/state.json. They do not run build.run, prepare.run, or validate.run.

For plugin authors, this creates a clear rule: keep config.mutate and launch.plan idempotent planning phases. They should compute config and service specs. They should not create external resources, mutate databases, start background processes, or perform setup that belongs in prepare.run.

For profile details, see devctl help profiles-guide.

11. Designing stable config schemas (keys and conventions)

Plugins don’t just start processes; they define the “shape” of the dev environment through config keys. If you treat config keys as an API, teams can safely build on them: scripts can read them, other plugins can extend them, and developers can learn them once and reuse that knowledge across repos.

The goal is stability and predictability. A key name should answer: “what is this?” without requiring the reader to reverse-engineer the plugin.

The most common patterns are:

  • env.<NAME>: environment variables you want to pass to services (or to the frontend dev server).
  • services.<service>.port: a service’s configured port (even if the service command doesn’t use it directly, humans will).
  • services.<service>.url: a service’s public URL (useful for other services and for validations).
  • artifacts.<name>: stable paths to build outputs (only if you want other tools to consume them).

Here is a representative “config tree” for a typical repo:

flowchart TD
  R[config] --> E[env]
  R --> S[services]
  R --> A[artifacts]

  E --> E1[VITE_BACKEND_URL]
  E --> E2[DATABASE_URL]

  S --> B[backend]
  S --> W[web]

  B --> BP[port]
  B --> BU[url]

  W --> WP[port]

  A --> AB[backend_bin]

11.2. Naming guidelines that keep you out of trouble

  • Prefer “nouns” over “verbs” for keys:
    • good: services.backend.url
    • less good: services.backend.start-command (that belongs in launch.plan)
  • Keep service names stable:
    • your service name in launch.plan is the identity for devctl logs --service <name>
    • don’t rename it casually
  • Don’t overload env.*:
    • use env.* for values you actually intend to export to processes
    • keep internal plugin-only values under services.* or artifacts.*
  • Treat keys as an API:
    • changing a key is a breaking change
    • adding a new key is usually safe
    • removing a key should be done with a deprecation window and a clear migration note

11.3. Versioning and deprecation (a practical approach)

You don’t need a formal semver scheme inside config, but you do need a plan for change.

  • When introducing a replacement key:
    • continue writing the old key for a short period (if you must), and document the planned removal
    • emit a warning from validate.run telling developers what changed
  • When removing a key:
    • do it as a deliberate change with a clear changelog entry
    • ensure any dependent scripts are updated in the same PR

If you have multiple plugins stacking on each other, favor compatibility at the edges (stable keys) rather than compatibility inside service commands.

12. Timeouts, cancellation, and dry-run

Plugins operate in messy environments: someone’s machine is slow, a subprocess hangs, Docker isn’t running, or a developer just wants to see what would happen without actually changing anything. devctl provides ctx.deadline_ms and ctx.dry_run, but your plugin still needs to enforce timeouts and safe behavior explicitly.

  • ctx.deadline_ms is a hint:
    • wrap external commands with your own timeouts
    • for shell invocations, timeout <dur> <cmd> is often good enough
  • devctl may terminate your process group:
    • treat SIGTERM as expected
    • keep state idempotent so reruns are safe
  • ctx.dry_run should mean “no side effects”:
    • skip docker compose up, pnpm install, DB resets, etc.
    • it is fine to compute plans and print intended actions to stderr

13. Debugging and test strategy

Good plugins are boring: they behave deterministically and fail loudly with actionable messages.

  • Make failures obvious:
    • write a single-line stderr log before running each external command
    • on error, include the command and exit code in stderr
  • Reproduce quickly:
    • keep a small “fixture plugin” that simulates failures (timeouts, bad health, validation fail)
    • keep smoke tests that run a full up/status/logs/down loop in a temp repo

In this repo, the fixture patterns live under devctl/testdata/plugins/ and smoke tests are runnable via go run ./cmd/devctl dev smoketest ... (for example: dev smoketest e2e, dev smoketest logs, dev smoketest failures, dev smoketest supervise).

14. Troubleshooting: the common failure modes

Most plugin failures are protocol failures. This section is a checklist for the issues that tend to bite in real use.

  • Protocol contamination:
    • symptom: devctl fails with a protocol/stdout error
    • cause: your plugin printed non-JSON to stdout
    • fix: move prints to stderr; ensure stdout emits only json.dumps(obj) + newline
  • Missing handshake:
    • symptom: start fails with handshake timeout
    • cause: the first stdout frame was not a valid handshake
    • fix: emit handshake immediately and flush
  • Op timeout:
    • symptom: context deadline exceeded
    • cause: plugin got stuck (hung process, waiting on network, deadlock)
    • fix: add per-command timeouts and log the exact command you ran
  • Health timeout:
    • symptom: tcp health timeout or http health timeout
    • cause: service never bound, bound the wrong address, or HTTP never returns 2xx–4xx
    • fix: validate ports/URLs when producing launch.plan and log the readiness target

15. Cookbook: real plugin examples

The devctl repo includes a suite of fixture plugins under testdata/plugins/. These are the best reference for how specific features work in practice.

ExampleFileWhat it demonstrates
E2E full pipelinetestdata/plugins/e2e/plugin.pyconfig.mutate, validate.run, build.run, prepare.run, and launch.plan with multiple services and health checks.
HTTP servicetestdata/plugins/http-service/plugin.pyA single service with an http health check on a real Python HTTP server.
Pipeline basicstestdata/plugins/pipeline/plugin.pyconfig.mutate, validate.run, and launch.plan in one minimal file.
Dynamic commandstestdata/plugins/command/plugin.pyHow to expose a custom devctl <cmd> via command.run.
Streaming logstestdata/plugins/stream/plugin.pyEmitting event frames for log streaming.
Validation failurestestdata/plugins/validate-passfail/plugin.pyReturning valid: false with actionable errors.
Launch failuretestdata/plugins/launch-fail/plugin.pyHow a service that fails to start is reported.
Noisy pluginstestdata/plugins/noisy-handshake/plugin.pyWhat not to do (printing to stdout contaminates the protocol).

Use these as starting points when you need to implement a new capability:

cp testdata/plugins/e2e/plugin.py ./myrepo-plugin.py
# Then adapt the service names and commands to your repo.

16. Reference: schema cheatsheet

This section summarizes what devctl expects at the JSON boundary. Use it when you’re implementing a plugin in a new language and just need the “shape of things”.

  • handshake.capabilities.ops: list of operations you implement
  • request:
    • request.op: one of your supported ops
    • request.ctx.dry_run: skip side effects when true
    • request.ctx.deadline_ms: time budget hint; enforce your own timeouts
  • response:
    • ok=true: parse output according to the op
    • ok=false: provide error.code and error.message
  • event:
    • emit for stream operations until you send event=end