Imported from rschlaefli/devrouter (
.agents/skills/devrouter/SKILL.md). Install upstream withnpx skills add rschlaefli/devrouter --skill devrouter. Copyright stays with the author.
devrouter
Local dev routing via a shared Traefik reverse proxy. Provides stable *.localhost hostnames for HTTP apps and TCP/Postgres multiplexing on shared ports (80, 443, 5432).
How it works
- Shared Traefik router owns host ports 80 (HTTP), 443 (HTTPS), 5432 (Postgres TCP).
- Per-repo config:
.devrouter.yml(single source of truth). - Global runtime artifacts:
~/.config/devrouter(never edit manually). - Hostnames must end with
.localhost(lowercase alphanumeric + hyphens only).
.devrouter.yml entry schema
version: 1
devrouter:
version: <semver> # required for devrouter -V / devrouter upgrade
project:
name: <string> # optional
apps:
- name: <string> # unique within repo
kind: app | dependency # optional, default: app
dependencies: # optional
- app: <other-name>
envMap: # optional; maps target env var name -> per-dep source var name
DATABASE_URL: <UPPER_DEP_NAME>_URL
# if kind=app:
host: <name>.localhost
protocol: http | tcp
runtime: host | docker | proxy
# if kind=app and runtime=proxy (protocol http or tcp):
upstream: 127.0.0.1:3000 # already-running port to route to; no lifecycle/deps
# Optional for HTTP proxy apps only:
readiness:
path: /api/health
statuses: [200] # default when omitted; explicit unique 2xx/4xx, no redirects
contentType: application/json # optional case-insensitive MIME base type
# Loopback (127.0.0.1/localhost) -> host.docker.internal (a published host
# port). A non-loopback name is passed verbatim and resolved over devnet —
# so a devcontainer container ON devnet (with a network alias) can be fronted
# by NAME with NO published host port: upstream: <alias>:3000. This is the
# collision-free way to run many apps at once (each its own *.localhost).
# upstream may use the ${WORKSPACE} placeholder (e.g. ${WORKSPACE}-app:3000)
# to target a per-workspace devcontainer alias — substituted with the resolved
# workspace token at runtime. See "Workspace isolation" below. Do NOT put
# ${WORKSPACE} in `host` (rejected); the host is auto-namespaced.
# Managed `ensure` requires every HTTP/TCP upstream to begin with the exact
# resolved workspace/project alias prefix before DevPod or route mutation.
#
# proxy + tcp (front a DB in an externally-managed container, e.g. a
# devcontainer's Postgres on devnet) — no per-DB host port:
# protocol: tcp
# tcpProtocol: postgres # selects shared entrypoint :5432
# upstream: <db-alias>:5432 # devnet alias of the DB container
# Requires `devrouter tls install` (SNI is read from the TLS ClientHello). Connect
# with direct-SSL so the ClientHello carries SNI, e.g.:
# psql "host=db.<app>.localhost port=5432 sslmode=require sslnegotiation=direct ..."
# if kind=app and runtime=host (protocol must be http):
hostRun:
command: <string>
cwd: <string> # relative to repo root, must not escape it
portTimeout: 120 # seconds, optional
strategy:
type: auto
denyPorts: [80, 443, 5432]
allowPortRange: '1024-65535'
# if kind=app and runtime=docker:
docker:
service: <string>
internalPort: <number>
composeFiles: [<string>] # relative to repo root
router: <string> # optional
# if kind=app and protocol=tcp:
tcpProtocol: postgres # required; runtime must be docker OR proxy
# if kind=dependency:
runtime: docker
docker:
service: <string>
composeFiles: [<string>] # relative to repo root
Validation rules:
kind=app:hostmust end with.localhostkind=app:runtime=hostsupportsprotocol=httponlykind=app:runtime=proxysupportsprotocol=httporprotocol=tcp, requiresupstream(host:port), and forbidshostRun/docker/dependencies(it only registers a route to an externally-managed upstream).protocol=tcpadditionally requirestcpProtocoland TLS (devrouter tls install)kind=app:protocol=tcprequiresruntime=docker(devrouter-managed container) orruntime=proxy(externally-managed upstream), plus a supportedtcpProtocol(postgres/redis/mariadb/mysql)kind=dependency: must useruntime=dockerand does not allow routed fields (host/protocol/tcpProtocol/hostRun/docker.internalPort/docker.router)- Unknown keys rejected (strict schema)
Docker compose requirements
- Healthcheck required: every dependency service must define a
healthcheck.docker compose up --waitblocks until healthy; without one, wait returns immediately. - No published ports: services must not publish host ports for devrouter-owned ports (80, 443, 5432). Avoid publishing ports at all -- devrouter handles routing via Traefik.
- Postgres credentials: use
POSTGRES_USER=prisma,POSTGRES_PASSWORD=prisma,POSTGRES_DB=prismaand create ashadowdatabase. devrouter injects per-dep{PREFIX}_URL/{PREFIX}_SHADOW_URLwith these credentials. - Persistent volume warning: if postgres defaults changed on an existing volume, reconcile credentials/data or recreate volumes when safe (for example
docker compose down -v).
Example healthcheck:
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U prisma -d prisma']
interval: 5s
timeout: 3s
retries: 20
Profiles
HTTP app readiness contracts use a same-host absolute path without queries,
fragments, percent escapes, backslashes or dot segments. Redirects are not followed.
Without a contract, the root probe remains route liveness rather than semantic
application proof. A declared contract failure returns a nonzero ensure result
with applicationReadiness.status=application-error; tools and routes remain
available for application debugging. Do not repair it by clearing caches or
recreating the provider. Live verification consumes the same contract.
Optional named subsets of routed apps in .devrouter.yml so ensure can start only what a task needs:
profiles:
manage:
apps: [manage, api, auth]
readiness: [manage, api]
pwa:
apps: [pwa, api, auth]
readiness: [pwa, api]
full:
apps: ['*']
default: true
apps(required for app-only profiles): routed app names (kind=app) or['*']for everything; withmanagedRuntime, omit it for a route-free capability profile.dependencies(optional):kind=dependencyservices this profile needs; omitted = every dependency a kept app requires transitively.readiness(optional): subset of the profile's apps thatensureHTTP-probes; omitted = all profile apps with an http route.default(optional): at most one; used when--profileis omitted. Noprofileskey at all = implicit full behavior.- Validation is strict at config load: unknown keys, non-routed
apps, non-dependencydependencies,readinessoutside the profile's apps, and multiple defaults are rejected. - Selection:
devrouter ensure <path> --profile <name>. Comma-separated selections (--profile manage,pwa) merge with deduplication; the canonical name is sorted-unique so order never affects identity or fingerprints. A wildcard member collapses to everything. - Managed adapters receive
DEVROUTER_PROFILE(canonical resolved name) in the post-start env; profile switches replace the owned process group via the fingerprint. - Adapters may pass
--prepare-command <command>todevrouter-process ensurefor synchronous dependency preparation under the process lock before application launch. Unchanged owned processes skip preparation; preparation participates in default fingerprints and must not daemonize or detach. - Managed Devsy startup on a supported local Unix Docker endpoint captures exact stop ownership before application readiness. With that baseline, canonical stop tolerates changed or missing repository configuration while preserving containers and volumes. Older records without a baseline retain configuration-dependent stop; installing a new CLI does not create historical ownership proof. Invalid or changed ownership never falls back. Do not replay a destructive bootstrap hook to obtain a baseline.
Managed devcontainer resources
The optional managedRuntime registry separates the primary container's base
Compose services, optional profile services, and repository-owned process
markers. Profiles select those dimensions independently:
managedRuntime:
devcontainer:
baseServices: [postgres]
profileServices: [litellm, mcp-server]
processes: [web, local-mcp]
profiles:
ai:
apps: [web]
devcontainerServices: [litellm]
processes: [web]
mcp:
devcontainerServices: [mcp-server]
processes: [local-mcp]
full:
apps: ['*']
devcontainerServices: ['*']
processes: ['*']
default: true
baseServicesremain selected with the primary Dev Container service for every managed profile;profileServicesandprocessesare registries.devcontainerServicesselects registered optional Compose services, andprocessesselects registered managed process markers.- With
managedRuntime,appsmay be omitted for a route-free capability profile. An omitted optional dimension selects nothing, so an app-only profile does not start LiteLLM, MCP, MailHog, or another optional service. - Use
*or thefullprofile to select every registered resource. A config withoutmanagedRuntimekeeps the app-only profile behavior. - Native Dev Container clients still use the source configuration's full
service set. Managed
ensurederives an ignored, marker-owned sibling configuration and changes onlyrunServices. - Warm profile changes retain the exact DevPod and volumes, do not rerun
postCreateCommand, avoid--recreateand broaddown, and stop dropped services/processes only after exact ownership proof. Routes publish last. - Inspect managed desired, active, and drift state with
devrouter statusanddevrouter doctor; values such as credentials and environment contents are never written to managed runtime state. - A managed adapter paired with
postCreateCommandrequireswaitForexactlypostCreateCommandorpostStartCommandbefore provider mutation. Generated selective configuration preserves lifecycle fields and changes onlyrunServices.
Automation profile plans
Use devrouter profile resolve --json when automation only needs exact selected
resources. Use devrouter profile plan when a repository-owned contract must
bind selected app names to build arguments, readiness URLs, or other literals:
devrouter profile plan --repo . --profile <selection> \
--contract <repo-relative-yaml> --output <plan.json> --json
The version-1 contract is separate from .devrouter.yml. It maps every allowed
app to named non-empty string arrays, constrains dependencies and managed
services to allowed sets, and requires an exact managed-process set. All
resource names must exist in .devrouter.yml. The path must be a regular,
non-symlink file inside the repository. Devrouter emits canonical, deduplicated
literal arrays and atomically writes mode 0600; it never interprets binding
names, expands values, or runs a shell or runtime. Consumers validate expected
keys and pass values as literal data. Keep separate contracts for workloads
with different exact process policies.
Env var injection
When a host app depends on a TCP Docker service, devrouter app run and devrouter app exec inject per-dep deterministic vars (where {PREFIX} = dep.name.toUpperCase().replace(/-/g, "_")):
| Variable | Value |
|---|---|
{PREFIX}_HOST |
localhost |
{PREFIX}_PORT |
random mapped port |
{PREFIX}_URL |
protocol-specific URL (postgres, redis, mysql/mariadb) |
{PREFIX}_SHADOW_URL |
postgres://prisma:prisma@localhost:<port>/shadow (postgres only) |
Host apps also receive PORT (random free port), HOSTNAME=0.0.0.0, HOST=0.0.0.0.
Config-level envMap on dependency references aliases per-dep vars to app-expected names (for example DATABASE_URL: DB_URL maps the per-dep DB_URL to DATABASE_URL).
Workspace isolation (parallel git worktrees / agents)
For exhausted address pools, inspect doctor --json before proposing changes.
Only an operator-owned network-policy.json under Devrouter home grants pools
and daemon authority. New eligible linked Compose workspaces default to /26;
managedRuntime.network.prefixLength accepts 24, 25 or 26 within policy, and
endpointUpperBound declares lifecycle demand. Operator approval is required
before activating policy or changing live Docker/provider settings. Unknown
routes block allocation; OrbStack remains diagnostics-only until guest routes
are qualified. Stop retains subnet claims, and zero active endpoints do not
prove cleanup safety.
Run several worktrees of one repo in parallel without host/route collisions. A workspace token spans the workspace-runtime id, devrouter routes, ${WORKSPACE} proxy upstreams, and devcontainer aliases.
- Identity: each managed linked worktree stores a local token in Git metadata plus a durable owner record in the repository's Git common directory. The record survives linked-worktree removal and binds the exact path to its workspace-runtime ID. First use reconciles persisted metadata, the exact-path owner record, and both DevPod and Devsy registries. It reuses an established agreement, keeps the readable sanitized branch/path slug when free, or claims a deterministic hash-suffixed fallback on collision before provider or route mutation. Later flags or
DEVROUTER_WORKSPACEmay repeat the identity but cannot rename it. Unreadable or conflicting evidence fails closed. The primary checkout remains non-namespaced. - When active: hosts auto-namespace (
web.localhost→web.<ws>.localhost),${WORKSPACE}inupstreamis substituted with the token, and the dockerrouterkey is suffixed per workspace. Managedensurerejects every HTTP/TCP proxy upstream outside that exact alias namespace before it mutates DevPod or routes. The runtime config is computed in memory only — the committed.devrouter.ymlis never rewritten. - Fixed host ports: managed
ensureresolves every fixed published binding in the effective compose model — profile services included — with the start's own interpolation env and refuses before any provider bootstrap when a running container already binds one (hostPortConflictsin--json, nonzero exit, naming the holder container, compose project, and owning workspace when attributable). Ephemeral (host::container) bindings are exempt. Devrouter never rewrites or offsets consumer-declared ports: stop the holding workspace or use the consumer's own override, then ensure again. The refused start stays visible indevrouter statusas astart-refusedattention reason with the read-onlydoctorcheck and the consumer-side fix; an explicitstopreleases the intent and the reason, and a later admitted ensure clears it. Unverifiable evidence refuses fail-closed.doctorreports the same drift read-only asrepo.host-port-claims(warn when evidence is unavailable). - TLS: namespaced hosts (
web.<ws>.localhost) are not covered by the*.localhostwildcard; devrouter auto-extends the mkcert cert SANs for active hosts when TLS is enabled. - devcontainer integration: managed scaffolds list the base compose file, then
${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}; custom repositories may keep another default overlay. Selecting.devcontainer/docker-compose.devrouter.ymlfor linked worktrees must passWORKSPACEandDEVROUTER_WORKSPACEacross the combined base/overlay config and bind-mount${DEVROUTER_GIT_COMMON_DIR}to the same absolute app-container path. The app exposes${WORKSPACE}-app; the proxy usesupstream: ${WORKSPACE}-app:<port>. - After a drained interrupted ensure, manual exec can use an exactly proven retained Devsy runtime with the plain local Docker command. Fresh identity proof remains required for subsequent tooling until later preparation reconciles startup. Preserve configuration, data and interrupted history; never replay uncertain execution. This does not repair absent stop baselines or provide OOM protection.
- Lifecycle: after one-time
setup, useensure .for both primary and linked checkouts; never branch manually on checkout kind or use live verify as startup.stop .is non-destructive;stop . --deleteis explicit exact-owner cleanup without worktree removal; andexec . -- <command...>runs one-shot commands only in the exact running workspace runtime (DevPod or Devsy). Never substitute rawdevpod up,stop,delete, or the Devsy equivalents: they bypass devrouter's machine-global ownership lock, which serializes provider mutations in a fair arrival-order queue and lets contenders wait up to thirty minutes with throttled stderr progress before failing with the queue position or holder PID and true durations. Runtime selection is path-aware:DEVROUTER_WORKSPACE_RUNTIME=devpod|devsyforces one runtime, an exact-path registry owner wins next, then the machine preference fromdevrouter setup --workspace-runtime, then installed-CLI auto-detection.workspace upcreates linked worktrees; destructive worktree removal and GC remain ledger-scoped. Dirty or locked full down fails before side effects. For Devsy, rundevrouter setup --yes --workspace-runtime devsyonce per installed Devsy release, rerunning it after a Devsy update, so Devrouter acquires and verifies the official Linux agent in its own machine cache. Devrouter supports releases in the range >=1.16.2 <2.0.0, verifies a release's published asset against the SHA-256 digest GitHub reports, and never injects a release without one.doctorreportsready,missing,stale, orinvalidwithout network access — a supported release with no recorded manifest isreadywith a drift warning and no injected agent untilsetuprecords one, while a version outside the supported range or unparseable staysstale— andensurefails before the provider queue when readiness is notready. An explicitDEVSY_AGENT_BINARYremains authoritative and must match the official asset for the installed supported release.ensureandstopstream bounded phase progress on stderr, so parse command JSON from stdout only. - Interrupted lifecycle: ensure reconciles an interrupted or never-dispatched ensure after positive worker drainage and automatically repairs retained degraded runtimes. Unknown arbitrary exec is never replayed; explicit stop reconciles that uncertainty. Preserve journals and retained configuration when evidence is unavailable. This does not detect or prevent OOM; bounded automatic recovery runs only when the operator enables the capacity
recoverypolicy. - Capacity admission (opt-in): only an operator-enabled
capacity-policy.jsonunder Devrouter home with an enrolled checkout routesensureandexecthrough controller admission. The CLI follows the decision with bounded reconnecting waits that survive controller restarts while the request stays queued, worker results are journalled atomically, andstopbypasses admission. Without an enabled policy lifecycle behavior is unchanged (ADR 0008). An optionalrecoveryblock adds bounded automatic recovery: one journal-admitted correctiveensureper positively failed required capability, bounded by per-scope restart counts, an aggregate corrective-action cap and a rolling observation window. It is inert unless enabled. - Lost capacity ledger: a stop whose physical cessation is already proven completes even when the ledger is positively absent; other ledger states keep refusing. When
doctorreportscapacity-ledger-lostfor a positively absent ledger, the forward recovery isdevrouter capacity reconcile --yes. It refuses while any journal settlement is outstanding, naming each blocking environment with its owndevrouter stop <path>, refuses when a declared runtime domain cannot be observed, and reconstructs no charges: journals are bounded and a charge that was never journal-bound is gone. - Busy
ensureandexecwait up to thirty minutes for a positively identified active lifecycle worker on the same checkout, with stderr progress. Do not stop healthy work to make room. Commands stay serial; timeout or cancellation before admission leaves existing work untouched. A lifecycle fence change cancels waiting admission, and unknown completion never permits replay. - Managed process identity:
ensureexecutes an exact captured adapter snapshot. Default reuse includes command argv, workspace, and adapter SHA-256. SetDEVROUTER_PROCESS_FINGERPRINT_ENVonly to comma-separated non-secret environment names whose values affect runtime identity; secret-like names are rejected and raw values are never persisted. - Route state: the versioned Traefik dynamic file is authoritative for both metadata and rendering. JSON is a compatibility mirror; valid headerless generations migrate automatically, while corrupt canonical metadata fails closed.
- Cleanup:
workspace cleanup --repo . --inactive-for 30d --jsonis a report-only, no---yesreport for managed linked workspaces. It joins ownership (present|missing|locked|conflict), workspace runtime registration, runtime state (running|stopped|busy|not-found|absent|unknown), checkout, route, advisory activity, and integration evidence without mutating the workspace runtime, routes, ownership, Git, Docker, applications, worktrees, or branches. Local DevPod/Devsy list/status checks always run;--check-mergedalone enables read-only origin and matching GitHub/GitLab checks. Treatnot-foundas stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicitgc/downcan remove exact stale registration only after expected-IDNotFoundproof and ownership revalidation. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook. - Sizing:
--measure-sizeadds per-workspace storage consumption to that report and stays read-only, but it walks each worktree and runsdocker ps/docker inspect --size, so leave it off when you only need the evidence states. Each row reports reclaimableworktreeandcontainerWritablebytes, non-reclaimableimageSharedbytes, and areclaimabletotal of the first two.imageSharedcovers image layers shared with other containers and overlaps across rows, so never sum it. Attribution is the workspace's own app container; sibling compose services such as a database are excluded. Any untrustworthy figure reportsunknownwith a reason rather than zero, and a workspace with no container reports a measured0. - Boundary: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a
.devrouter.ymlfolder without.git.
Secret manager interop (Infisical/Doppler)
- Config-based SM integration: set
secretManager.commandin.devrouter.yml(include trailing--). devrouter wraps commands and re-injects dep env vars after the SM boundary. secretManager.defaultEnv: optional fallback environment for{env}template in command string.{env}template placeholder:secretManager.command: "infisical run --env {env} --"resolved at runtime.--env <env>CLI flag overridesdefaultEnv.- Example config:
secretManager: command: infisical run --env {env} -- defaultEnv: dev - Use
envMapon dependency references to alias per-dep vars to app-expected names:dependencies: - app: db envMap: DATABASE_URL: DB_URL DIRECT_URL: DB_URL SHADOW_DATABASE_URL: DB_SHADOW_URL - Prefer argv-safe command forms. Do not wrap
infisical runordoppler runinsh -lcunless shell expansion is strictly required. - Canonical Infisical migrate command:
devrouter app exec <app> --yes -- infisical run --projectId <id> --env=<env> -- pnpm payload migrate - Canonical env probe command (run before migrate/seed):
devrouter app exec <app> --yes -- printenv DB_URL DB_HOST DB_PORT DB_SHADOW_URL - Canonical Doppler migrate command:
devrouter app exec <app> --yes -- doppler run -- pnpm payload migrate - Precedence best practice: avoid defining per-dep var names in Infisical/Doppler when you expect devrouter local DB injection.
- Precedence best practice: store remote/prod URLs under non-conflicting names (for example
PROD_DATABASE_URL) and map intentionally viaenvMap. - Precedence best practice: if secret manager must define DB vars, run the env probe and verify values before any migration/seed.
- Use
devrouter app exec --shell -- "<single command string>"only when shell expansion is required. envMapfails fast when source var is missing so migrations do not run with partial mapping.
Upgrade handling (required)
- Keep
.devrouter.ymlmetadatadevrouter.versionaligned with the currently applied devrouter release. - Verify versions with
devrouter -V(shows installed CLI version, local repo version, and next upgrade target). - Use
devrouter upgradeto list available upgrade targets anddevrouter upgrade <version>to print that target's Agent Adaptation Prompt fromupgrade-prompts/<version>.md. - Do not assume user-provided instructions include all required adaptation steps.
- After upgrading the CLI in a dependent repo, refresh discoverability artifacts with
devrouter repo agents(ordevrouter init --write-agents --write-skill). - Re-run validation after upgrade:
devrouter doctor --repo .,devrouter app ls --repo ., one representativedevrouter app execflow, anddevrouter ls.
Commands
-
devrouter init [--write-agents] [--write-skill]: print AI onboarding prompt (non-mutating by default) -
devrouter -V [--repo .]: show installed CLI version, local repo version, and next upgrade target -
devrouter upgrade [version] [--repo .]: list upgrade targets or print target Agent Adaptation Prompt -
devrouter setup --yes [--repo .] [--json] [--workspace-runtime <devpod|devsy>]: first-run machine setup plus structured diagnostics; explicit Devsy selection acquires its verified agent -
devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]: canonical startup/reconciliation for primary and linked checkouts; automatically recovers retained degraded state into the requested profile after ownership proof, without starting dropped processes first; explicit repair uses the recorded profile only -
devrouter profile resolve --repo <path> [--profile <selection>] [--json]: resolve exact profile resources for automation without starting or inspecting a runtime -
devrouter profile plan --repo <path> [--profile <selection>] --contract <repo-relative-yaml> [--output <path>] [--json]: validate repository-owned resource policy and emit literal bindings without runtime access -
devrouter stop [path] [--delete] [--json]: stop the exact workspace runtime and remove exact routes;--deleteexplicitly deletes its ownership-proven data without removing the checkout -
devrouter exec [path] -- <command...>: literal one-shot command inside the exact running workspace runtime -
devrouter harness gate [--repo <path>] [--wait-budget-ms <ms>] [--json]: defer one harness tool call until this checkout's lifecycle phase settles; prints the harness permission decision -
devrouter capacity reconcile --yes [--json]: replace a provably absent capacity ledger with a fresh empty baseline once no journal-visible charge remains -
devrouter up/devrouter down: start/stop shared Traefik router -
devrouter status: router/container/network/TLS health -
devrouter doctor [--repo .]: deep diagnostics (global + repo) -
devrouter ls: list active HTTP + TCP routes -
devrouter open <name>: open HTTP route or print TCP connection hint (matches app name, then service/container/host identities) -
devrouter logs [-f]: Traefik access logs -
devrouter tls install: install mkcert certs, enable HTTPS + TCP/SNI -
devrouter repo init: create.devrouter.yml -
devrouter repo inspect [--json]: inspect package, scripts, compose services, env names, devcontainer, devrouter config, and agent guidance for onboarding -
devrouter repo devcontainer write --dry-run --json: plan conservative Node/pnpm/Postgres devcontainer/devrouter scaffold files without writing -
devrouter repo devcontainer write --yes: write managed Node/pnpm/Postgres devcontainer/devrouter scaffold files when no custom-file conflicts exist -
devrouter repo devcontainer verify --json: emit read-only onboarding evidence for PRs -
devrouter repo devcontainer verify --live --yes --json: deprecated compatibility verification afterensure; never use as startup -
devrouter repo agents: write devrouter section in AGENTS.md + install this skill -
devrouter app add: add/update app entry in.devrouter.yml -
devrouter app ls: list app entries -
devrouter app run <name> [--env <env>] [--workspace <slug>]: run app with dependency lifecycle (--env overrides SM defaultEnv; --workspace overrides the per-workspace token) -
devrouter app exec <name> [--shell] [--env <env>] [--workspace <slug>] -- <cmd>: one-shot command with resolved dep env -
devrouter app rm <name> [--keep-config]: remove app entry (--keep-configfrees only the live route/hostname, leaves.devrouter.ymluntouched) -
devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]: create a worktree and start/prove it unless create-only mode is requested -
devrouter workspace ensure [path] [--profile <name>] [--repair] [--open] [--json]: compatibility alias ofdevrouter ensure -
devrouter workspace ls [--json]: list ownership, Git, workspace runtime, route, path, and branch evidence -
devrouter workspace cleanup [--repo .] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]: report-only cleanup evidence and exact guarded suggestions; no--yesor apply mode -
devrouter workspace stop <workspace|branch>: stop DevPod and routes; preserve checkout, owner record, and data -
devrouter workspace down <workspace|branch> [--keep-worktree]: delete runtime/routes and optionally remove the clean worktree and record -
devrouter workspace gc [--json] [--yes]: report missing owners by default; apply exact eligible cleanup with--yes -
devrouter workspace journal settle [path] [--json]: settle a lifecycle operation whose worker is provably gone as unobservable so ensure/stop can proceed; never hand-edit~/.config/devrouter
For host-generated Compose inputs, configure
managedRuntime.devcontainer.prepareCommand as literal argv. Ensure runs it once
in the checkout root before Compose inspection, under lifecycle serialization,
with a sixty-second bound. Keep .devrouter.yml unchanged and finish in the
foreground. Diagnostics never execute the hook. Qualify changed mounts separately
before relying on warm container reuse.
Observing consumer readiness
For ongoing readiness, explicitly start devrouter controller run, then enroll
the managed linked checkout with controller observe --session <id> --profile <profile> --require runtime. Use --require app:<name> for an application with
an explicit HTTP readiness contract. Keep the returned store, epoch, generation,
and session ID for renew, release, and watch requests. Renew every ten seconds;
status and watch do not renew the thirty-second lease.
Treat UNKNOWN, stale evidence, disconnection, or event gaps as unverified
readiness. Reacquire after observer restart. A failing application can coexist
with ready tooling; choose requirements for the actual work. Continue using
ensure, exec, and stop for lifecycle actions: observation does not authorize
automatic recovery, capacity admission, or command replay. Releasing observation
preserves the runtime, so still stop the exact environment after runtime work.
Agent harness gating
A managed consumer can gate agent tool calls so a command never starts while the
checkout's environment is mid-transition. Wire devrouter harness gate as a
PreToolUse hook. Claude Code reads it from a settings file:
{
"hooks": {
"PreToolUse": [
{ "matcher": ".*", "hooks": [
{ "type": "command", "command": "devrouter harness gate", "timeout": 90 }
] }
]
}
}
The Codex CLI reads the same hook from $CODEX_HOME/hooks.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": ".*", "hooks": [
{ "type": "command", "command": "devrouter harness gate", "timeout": 180 }
] }
]
}
}
.* matches every tool, including the shell, file-editing and MCP tools a model
can point at the environment. The gate answers a settled checkout immediately, so
the price is one short-lived process per gated call; narrow the matcher when that
price matters more than covering every tool.
The gate identifies the requesting harness from its payload and answers in that
harness's accepted shape: a refusal is a deny in both, while an allowed call is
permissionDecision: allow for Claude Code and Codex receives a bare completion
carrying the same guidance as additionalContext, because Codex rejects an
allow decision as unsupported hook output.
the checkout's durable phase is queued, starting, verifying, recovering
or stopping, and prints the harness permission decision. The deferral consumes
no model turns because the harness is blocked on the hook. --wait-budget-ms
bounds the wait (default 30000); a phase that outlasts it returns one deny that
names the phase and asks the agent not to retry automatically. Lifecycle
commands (devrouter ...) always pass through, and unreadable journal evidence
is allowed rather than blocking the agent. Set the harness hook timeout above
the wait budget: an overrunning hook is not honored, and the tool proceeds under
the harness's normal permission rules. Use --json for the devrouter decision
envelope with wait metrics.
Gated calls are keyed by the harness tool_use_id and recorded durably. If the
harness re-delivers a call it already granted or cancelled, the gate returns one
refusal naming the earlier decision instead of waiting again, because that call
may already have run; the agent should verify it and issue a new call. Entries
expire after 24 hours, the ledger keeps the newest 64 per checkout, and an
unreadable or unwritable ledger never blocks the agent. Payloads without a
tool_use_id keep the wait-only behavior.
Validation workflow
For devcontainer onboarding:
When Devsy owns the workspace, first run
devrouter setup --repo . --yes --workspace-runtime devsy --json.
devrouter setup --repo . --yes --jsondevrouter doctor --repo . --jsondevrouter repo inspect --repo . --jsondevrouter repo devcontainer write --repo . --dry-run --jsondevrouter repo devcontainer write --repo . --yesdevrouter repo devcontainer verify --repo . --json- Start and prove either checkout kind with
devrouter ensure . --json; for managed selective work, use--profile <name>and inspect desired/active/drift state - Run seeds or migrations with
devrouter exec . -- <command...>
For host/docker runtime apps only:
devrouter setup --repo . --yesdevrouter doctor --repo .devrouter app ls --repo .devrouter app run <host-app> --repo . --yesdevrouter lscurl -I https://<host>.localhost- For TCP/Postgres, use
devrouter open <name>for the connection hint.
Runtime behavior notes
- Managed devcontainer images contain no devrouter package or helper.
devrouter ensuredelivers its matching process helper to the exact running container and invokes the repository-owned.devcontainer/post-start.sh; keep.devrouter.ymlas the only consumer-side devrouter version pin. devrouter app runauto-starts Docker dependencies and waits for health. Host app runs stop auto-started docker deps on exit; docker app runs leave target services running until explicit cleanup.- Host-runtime dependencies are NOT auto-started (v1).
kind=dependencyentries do not create routes and cannot be direct targets fordevrouter app run,devrouter app exec, ordevrouter open.kind=dependencyservices start as declared in compose (no Traefik label wiring, no random port publishing, no injected env vars).- Postgres on shared
:5432requires TLS/SNI (devrouter tls install). Standard app clients should use the injected random port instead. devrouter app execfollows the same dep lifecycle for one-shot commands and preserves argv semantics by default (shell: false).devrouter app exec --shellis explicit and requires exactly one command string after--.- Secret-manager overlap caveat: if Infisical/Doppler defines DB vars too, probe effective env (
printenv DB_URL DB_HOST DB_PORT) before migrate/seed. - A lock that cannot prove its owner fails closed and names the failing inspection stage, the exact lock path, and a portable reproduction command. Run the canonical command in a permitted host context; there is no identity fallback.
Stop after pre-registration failure
If managed Devsy startup failed before registration, canonical devrouter stop <path> --json can prove absence for an exact ledger-owned linked checkout with
no retained state. Devsy-only installations do not require installing DevPod;
legacy local registry evidence must still be readable, complete and conflict-free.
Success preserves runtimeAbsent: true. Live workers, unknown ownership,
surviving containers or routes keep stop pending. Do not edit journals or
provider state to bypass a refusal. Revalidate the original startup blocker
before retrying ensure; retained-container identity drift needs separate proof.