Bundle your stack — automation steps + network services + config schema — as a single plugin file. Activate it from any project with one line in
asd.yaml. The same mechanism the bundledangularandsupabaseplugins use.
Level: 7 · Reading time: 30 min
caddy.* field, including rawRoutes.${{ env.X }} template expansion.Already know this rung? Skip to internals/architecture for the wider system layout, or internals/extending for the route to contributing real CLI commands back to asd itself.
Until now you've described services individually:
packages/<svc>/net.manifest.yaml per service,
asd.yaml per project. That's fine for one project, but it doesn't
travel — every team that wants the same stack copies the same set of
files.
A plugin packages all of it: automation hooks (asd up /
asd down steps), network service declarations, default
configuration, and a JSON-schema for the plugin's own settings. Any
project that wants the stack adds one line to its asd.yaml —
project.plugins: [your-plugin] — and the rest gets merged in.
Two bundled plugins ship with asd and are the worked examples to
read:
angular — adds asd angular env and asd angular preflightdev automation step that renders environmentsupabase — adds asd supabase bootstrap / start / stopstart automation toWe'll build a tiny plugin from scratch that follows the same pattern.
We'll build a plugin called local-mail that adds two things:
mailpit service on localhost:8025 (so anyone activating thedev automation step that starts mailpit before the user's ownA plugin is a single YAML file at
modules/plugin/local-mail/tpl.asd.yml inside the asd distribution
(for bundled plugins) or in your own asd-cli fork. The template
declares what should be merged into the user's project config when
the plugin is activated.
# modules/plugin/local-mail/tpl.asd.yml
version: 1
project:
name: "${PROJECT_NAME:-app}"
plugins:
- local-mail
automation:
dev:
# Mailpit binds SMTP on 1025 + HTTP UI on 8025. Background so the
# user's own dev steps (added in their asd.yaml) keep running.
- run: "docker run --rm -d --name local-mail -p 1025:1025 -p 8025:8025 axllent/mailpit"
background: true
network:
services:
local-mail:ui:
name: Mailpit UI
dial: "127.0.0.1:8025"
host: "${PROJECT_NAME}-mail.localhost"
publishPreferred: host
The project, automation, and network blocks get merged into
the activating project's config. The plugins_config.local-mail
slot (covered next) is where the project supplies per-installation
overrides.
A plugin can declare its own settings under
plugins_config.<plugin-name> in the user's asd.yaml. The
meaning of those settings is the plugin's own contract — asd's
core does not interpret them. The plugin owns validation via a Zod
schema at scripts/config.schema.ts, surfaced through
asd <plugin> preflight:
// modules/plugin/local-mail/scripts/config.schema.ts
import { z } from "zod";
export const LocalMailConfig = z.object({
smtp_port: z.number().int().min(1024).max(65535).default(1025),
ui_port: z.number().int().min(1024).max(65535).default(8025),
});
export type LocalMailConfig = z.infer<typeof LocalMailConfig>;
Bundled plugins implement preflight by importing this schema and
calling LocalMailConfig.parse(projectConfig.plugins_config["local-mail"]).
Errors surface as readable diagnostics in asd <plugin> preflight
output — that's what asd angular preflight does today.
How the plugin's runtime uses those validated values (e.g. picking
SMTP port from the config to pass to mailpit) lives in the plugin's
own command implementation — see internals/extending
for the route to adding that command to asd-cli.
In any project that wants local-mail:
# asd.yaml
version: 1
project:
name: my-project
plugins:
- local-mail
plugins_config:
local-mail:
smtp_port: 2525 # override default
# ui_port defaults to 8025
automation:
dev:
- run: "pnpm dev" # the user's own dev step
When asd net apply runs, it merges
modules/plugin/local-mail/tpl.asd.yml into the project's effective
config. The merged result has:
pnpm devautomation.dev.local-mail:ui service registered for routing.$ asd plugin info
local-mail
services declared: local-mail:ui (Mailpit UI, host: my-project-mail.localhost)
automation hooks: dev (+1 step before user steps)
schema: ok (validated against config.schema.ts)
$ asd net apply
✅ Apply complete: 1 seeded, 1 routes (local-mail:ui)
$ asd up
▶ asd local-mail start (from plugin)
▶ pnpm dev (from user)
The user opens https://my-project-mail.localhost/mailpit/ and sees
the Mailpit inbox. Their dev server still runs through pnpm dev.
Removing the plugin is symmetric: drop local-mail from
project.plugins, re-run asd net apply, the mailpit route is
unregistered and the automation step disappears.
Read the bundled plugins for the full pattern:
ls /home/$USER/.local/share/asd/modules/plugin/
# angular supabase
less /home/$USER/.local/share/asd/modules/plugin/supabase/tpl.asd.yml
Both bundled plugins are small (~50 lines) and use only the same
mechanisms documented above.
Three things to internalise:
Plugins are template + slot, not code. A plugin contributes
YAML that gets merged into the user's config. asd does the
merge; the plugin doesn't run any logic of its own at merge time.
This is why plugins compose cleanly: two plugins activated
together just merge two templates into the same project.
The plugins_config.<name> slot is the plugin's contract.
Users write their settings there; the schema file
(config.schema.ts) validates them; the plugin's own commands
read them when running. There's no automatic env-var mapping —
if a plugin wants its config to influence the network template,
that's up to its preflight / <command> implementations
(which write back to .env or read directly from the project
config). Look at the bundled angular plugin for a real example:
it defines a map_env block in its config that explicitly names
which env vars its command should produce.
Command surface lives in the asd-cli binary, not the plugin
file. asd local-mail start would need to be added to the
compiled asd-cli binary itself — your tpl.asd.yml can declare
the command in automation steps, but the implementation of a
new top-level subcommand requires a contribution to
asd-engineering/asd-cli.
For most plugins this is fine: you compose existing commands
(asd caddy start, asd net apply, docker compose up, …) inside
the automation block. Only when you need genuinely new behaviour does
the line move from "plugin" to "module / core contribution" — and at
that point the path is at internals/extending.
cookbook/ for single-problem recipes.internals/extending covers PR conventions, where plugin templates live in the source tree, and how the merge engine resolves conflicts.internals/architecture.reference/asd-yaml — project.plugins + plugins_config fields.