A practical playbook for writing, testing, and shipping devctl plugins.
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).
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
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:
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.
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.
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
.devctl.yamlAt the repo root:
plugins:
- id: myrepo
path: python3
args:
- ./plugins/myrepo-plugin.py
priority: 10
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.
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”.
If you print anything non-JSON to stdout, devctl treats it as protocol contamination and fails the plugin.
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]
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.
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).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.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" }
}
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).
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.
config.mutate: return a config patchconfig.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:
input.config as the current config and compute a patch from it.validate.run: report pass/fail with actionable errorsValidation 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:
build.run / prepare.run: steps and artifactsThese 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:
| Field | Type | Required | Description |
|---|---|---|---|
steps | StepResult[] | yes | Named steps that ran. |
steps[].name | string | yes | Step identifier (used for merging across plugins). |
steps[].ok | boolean | yes | Whether the step succeeded. |
steps[].duration_ms | number | optional | How long the step took. |
artifacts | map<string,string> | optional | Named paths to build outputs (e.g. {"backend-bin": "dist/server"}). |
Dry-run behavior:
ctx.dry_run is true, do not perform side effects.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.
launch.plan: describe services devctl should superviseThe 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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Stable identifier for logs, status, and down. |
command | string[] | yes | argv array. No shell parsing — use ["bash","-lc","..."] if you need a shell. |
cwd | string | optional | Working directory. Relative paths resolved against repo_root. |
env | map<string,string> | optional | Extra env vars merged with the parent environment. |
health | HealthCheck | optional | Readiness check before devctl considers up successful. |
health.type | "tcp" | "http" | yes | TCP probe or HTTP GET probe. |
health.address | string | for tcp | Host:port to dial (e.g. "127.0.0.1:8080"). |
health.url | string | for http | URL to GET. 2xx–4xx counts as healthy. |
health.timeout_ms | number | optional | How 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.
command.run: plugin-defined CLI commandsPlugins 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 } }
Once launch.plan returns, devctl takes over. Here's what happens to your services:
Process startup:
Setpgid: true) for each service.cwd is resolved relative to repo_root if not absolute.env is merged with devctl's own environment..devctl/logs/:
{name}-{timestamp}.stdout.log{name}-{timestamp}.stderr.logHealth checks:
health check to pass.Shutdown (devctl down):
.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).
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.
priority (lower first)id (stable)config_patch is applied in orderbuild.run, prepare.run):
namelaunch.plan):
nameThis is why stable names matter:
myrepo.backend, myrepo.web, db.resetbackend, web (fine in a single-plugin repo, collision-prone in stacks)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}"}})
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:
jq as required; if your repo can’t depend on it, write the plugin in Python or Go instead.echo JSON by hand; use jq -cn so escaping is correct.>&2 for all logs.launch.plan) rather than inside the plugin loop..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 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.
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.
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]
services.backend.urlservices.backend.start-command (that belongs in launch.plan)name in launch.plan is the identity for devctl logs --service <name>env.*:
env.* for values you actually intend to export to processesservices.* or artifacts.*You don’t need a formal semver scheme inside config, but you do need a plan for change.
validate.run telling developers what changedIf you have multiple plugins stacking on each other, favor compatibility at the edges (stable keys) rather than compatibility inside service commands.
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:
timeout <dur> <cmd> is often good enoughctx.dry_run should mean “no side effects”:
docker compose up, pnpm install, DB resets, etc.Good plugins are boring: they behave deterministically and fail loudly with actionable messages.
up/status/logs/down loop in a temp repoIn 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).
Most plugin failures are protocol failures. This section is a checklist for the issues that tend to bite in real use.
json.dumps(obj) + newlinecontext deadline exceededtcp health timeout or http health timeoutlaunch.plan and log the readiness targetThe devctl repo includes a suite of fixture plugins under testdata/plugins/. These are the best reference for how specific features work in practice.
| Example | File | What it demonstrates |
|---|---|---|
| E2E full pipeline | testdata/plugins/e2e/plugin.py | config.mutate, validate.run, build.run, prepare.run, and launch.plan with multiple services and health checks. |
| HTTP service | testdata/plugins/http-service/plugin.py | A single service with an http health check on a real Python HTTP server. |
| Pipeline basics | testdata/plugins/pipeline/plugin.py | config.mutate, validate.run, and launch.plan in one minimal file. |
| Dynamic commands | testdata/plugins/command/plugin.py | How to expose a custom devctl <cmd> via command.run. |
| Streaming logs | testdata/plugins/stream/plugin.py | Emitting event frames for log streaming. |
| Validation failures | testdata/plugins/validate-passfail/plugin.py | Returning valid: false with actionable errors. |
| Launch failure | testdata/plugins/launch-fail/plugin.py | How a service that fails to start is reported. |
| Noisy plugins | testdata/plugins/noisy-handshake/plugin.py | What 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.
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 implementrequest:
request.op: one of your supported opsrequest.ctx.dry_run: skip side effects when truerequest.ctx.deadline_ms: time budget hint; enforce your own timeoutsresponse:
ok=true: parse output according to the opok=false: provide error.code and error.messageevent:
event=end