Authoring Blocks
Stackie ships with a catalog of ready-made blocks — Postgres, Redis, Caddy, the Supabase pieces, and dozens more. When you need something that isn’t in the catalog, you write your own block. A block is a single YAML file. It tells Stackie what to install, how to start it, what ports it listens on, and how to know it’s healthy.
This page walks through the minimum viable block, then shows where Stackie looks for blocks on disk and how to validate them before you run a stack that depends on them.
A minimum block
Save the file as ~/.stackie/sources/local/echo.yml:
echo:
description: "Echoes a string and exits"
command: "echo hello from stackie"
That’s a complete, valid block. The top-level key (echo) is the block
name; everything underneath it describes the block. description is shown in
stackie blocks list; command is what Stackie runs when the block starts.
Use it in a stack:
name: hello
blocks:
- echo
Then stackie up hello.yml runs your block end to end.
Where Stackie looks for blocks
When you reference a block by name (echo, postgres, my-org.internal-svc)
Stackie searches each of these directories in order, stopping at the first hit:
<project>/sources/— relative to the stack file you ran~/.stackie/sources/— your user-level catalog- The built-in catalog embedded in
stackieitself
The first two are yours. Drop a .yml (or .yaml) file into either and the
block is discoverable. Subdirectories are walked recursively, so
~/.stackie/sources/db/postgres-17.yml and
~/.stackie/sources/local/echo.yml both work.
Naming and namespacing
A bare name like echo is fine for personal use. If you intend to share
blocks with a team or publish them, namespace the file with a dotted prefix:
my-org.payments-api:
description: "Internal payments API used by the checkout stack"
command: "node ./server.js"
ports:
- type: api
port: 7100
The dot in the name is a convention, not syntax — Stackie treats the whole
string as one identifier. It exists so that two organisations can both ship a
block called auth without colliding.
Adding ports, health, and packages
Most non-trivial blocks need at least one port and a health check:
my-org.payments-api:
description: "Internal payments API"
command: "node ./server.js"
ports:
- type: api
port: 7100
health:
type: http
path: /healthz
packages:
- source: node
package: payments-api
version: "1.4.0"
packages declares what Stackie needs to install before command runs. The
source field picks the provider (node, pip, brew, go, web, etc.);
the package and version are passed straight through to that provider.
The block authoring reference for every supported field lives in the Blocks Catalog — every block in the built-in catalog is itself a worked example.
Validating before you run
Stackie can lint a single block file or an entire directory and tell you exactly what’s wrong with each one before you waste a run on it:
stackie blocks lint ~/.stackie/sources/local/echo.yml
stackie blocks lint ~/.stackie/sources/
A clean run prints a one-line summary and exits with status 0. A failing
run prints a syntax-highlighted YAML excerpt, an underline pointing to the
offending key, a one-sentence cause, and a suggested fix. Exit status is
non-zero so you can wire stackie blocks lint into a pre-commit hook.
The same validator is exposed over MCP as the validate_block tool, and
the dashboard surfaces failures from the daemon as notifications — see
Reading validation errors for the full
tour of the error UX.
Plugin database conventions
Plugins run inside Stackie’s daemon and receive a deliberately narrow database
capability. Give your plugin a lowercase, dash-separated name such as
release-notes; Stackie derives its private table namespace from that identity.
Do not open the daemon database or choose a table prefix yourself.
Use the {table:identifier} placeholder wherever a plugin-owned table appears
in SQL. Stackie expands it only in executable SQL, never inside quoted text or
comments. For example:
context.database()?.with_connection(|database| {
database.execute_batch(
"CREATE TABLE {table:notes} (id INTEGER PRIMARY KEY, body TEXT NOT NULL)",
)?;
database.execute("INSERT INTO {table:notes} (body) VALUES (?1)", ["draft"])?;
Ok(())
})?;
The identifier after table: may contain ASCII letters, digits, and
underscores only. Plugin connections may read host tables when a feature needs
shared reference data, but SQLite rejects inserts, updates, and deletes outside
the plugin’s generated namespace. This keeps one plugin from changing Stackie
state or another plugin’s data.
Common authoring mistakes
| Mistake | What you’ll see |
|---|---|
Missing command | “block must define command” with the file underlined |
| Two top-level keys in one file | “expected a single block per file, found 2” |
| Tabs instead of spaces | YAML parser error pointing to the offending line |
port outside a ports: list | “ports must be a list of port objects” |
source without package | “package is required when source is set” |
When you see one of these, fix the file and re-run stackie blocks lint.
The lint output is the same shape the dashboard renders, so the fix loop
is the same whether you’re at the terminal or watching the tray.