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:

  1. <project>/sources/ — relative to the stack file you ran
  2. ~/.stackie/sources/ — your user-level catalog
  3. The built-in catalog embedded in stackie itself

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

MistakeWhat 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 spacesYAML 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.