Building Blocks
Building blocks are how you bootstrap a project fast. A block is a self-contained bundle — data objects, relationships, seed data, scheduled tasks, roles, event subscriptions, and (optionally) vendored business logic — packaged as a validated manifest that the server applies through its own APIs. Because a block creates real objects, REST/GraphQL/MCP light up for it instantly, with no extra wiring.
The model is deliberately shadcn-style: you install a block into your project and you own the result. Blocks are not a runtime dependency you’re locked into — they’re a starting point you can modify freely.
The mental model
Section titled “The mental model”registry / URL / local path ┌────────────────────────────┐ │ │ Ion Drive server │ ▼ │ BlockEngine → Schema/ │┌──────────────┐ 1. code/ files │ Data/Task/Role APIs ││ ion-drive add│ ───▶ blocks/<name>/ └────────────────────────────┘│ │ 2. manifest POST /api/v1/blocks/install ▲└──────────────┘ │ objects · relationships · seed · tasks · roles · subscriptions · actions · hooksInstalling is a two-part operation (ADR-018):
- Code is vendored — the block’s TypeScript (if any) is copied into your
project at
blocks/<name>/and wired into theblocks/index.tsbarrel. From that moment it’s your code: edit freely, the dev server hot-reloads, re-addnever overwrites your edits. - The manifest is installed — POSTed to the server, which validates it
(including that every declared action/hook has a registered handler — the
requirescontract), applies the schema, and records the_ion_blocksledger.
The block runtime in @ion-drive/core is content-agnostic — it
installs any validated manifest it’s handed.
Official catalog & registries
Section titled “Official catalog & registries”Official blocks live in the separate
jaredgrabill/ion-drive-blocks repository — one
directory per block, distributed through the exact same pipeline a third-party
block uses: a protocol-v1 registry (static JSON — a small index.json
directory plus one blocks/<name>.json version history per block; see
Block Registries).
| Block | Contents | Depends on | Logic |
|---|---|---|---|
crm | Contacts, Companies, Deals, Activities | — | — |
invoicing | Invoices, Line Items, Payments | crm: ^0.2.0 | Stripe payment links + webhook (code/) |
catalog | Products, prices, stock moves | invoicing: ^0.1.0 | Stock adjustment + invoice-line pricing (code/) |
communications | Message log, templates, campaigns | — | — |
audit | audit_log fed by the message bus | — | — |
Projects configure registries as namespaces in ion.config.json
(spec-03); the official registry @ion is built in and is the default for
bare refs:
{ "registries": { "@acme": { "url": "https://blocks.acme.internal/registry/index.json", "headers": { "Authorization": "Bearer ${ACME_REGISTRY_TOKEN}" } } }, "defaultRegistry": "@ion"}Dependencies are resolved recursively across registries — semver ranges are
collected over the whole closure and the highest version satisfying every
range is picked; the plan is applied in topological order with
already-installed blocks pruned. A block’s bare dependency names resolve in
the registry that block came from, never your default (the
anti-dependency-confusion rule). ION_DRIVE_REGISTRY still overrides the
default registry’s URL for one invocation. (The legacy registryUrl config
field is no longer read — declare the URL under registries instead.)
Manifest versioning
Section titled “Manifest versioning”Manifest v1 (spec-02) uses real semver semantics:
versionis a strict, canonical semver version (0.2.0,1.0.0-rc.1). Novprefix, no build metadata — anythingsemver.validwould normalise away is rejected.dependenciesis a name → semver-range record, not an array:{ "crm": "^0.2.0" }."*"is the unconstrained escape hatch. Refs may be namespaced ("@acme/billing": "^1.2") — a namespace names a registry source, never a separate identity, so the server matches by bare name.requires.coreis a semver range the running core version must satisfy (e.g.">=0.2.0 <1.0.0"), checked before anything is applied.
Blocks are singletons per server — exactly one version of a block is installed at a time, so ranges are compatibility constraints the installer checks, never an npm-style multi-version solver problem.
Discovering blocks
Section titled “Discovering blocks”Registries are searchable and agent-friendly without ever running a server
(spec-08 — everything below is plain static files emitted by
ion-drive registry build):
npx ion-drive search invoicing # search the default registrynpx ion-drive search billing -r @acme # search one configured registrynpx ion-drive registry add @acme # no URL: look @acme up in the directoryion-drive search <term>matches name, title, description, and categories. When the registry’sindex.jsonadvertises asearchUrl, the prebuiltsearch-index.jsonis used; otherwise (or if it’s unusable) the CLI falls back to substring matching over the index — so every protocol-v1 registry is searchable.--jsonfor machines.ion-drive registry add @ns(no URL) resolves the namespace through the main registry’s PR-reviewedregistries.jsondirectory, shows owner/URL/description for confirmation, then validates and writes config exactly like the URL form. Unknown namespace? Pass the URL explicitly — that always works and is the private-registry path.- Registries also serve badges (
badges/<name>.svg— shields-style name + version + trust hint for READMEs) and a copied per-block README (registry/blocks/<name>.readme.md, advertised via the block doc’sreadmeUrl). All of these are mutable display data regenerated byregistry build— never release artifacts.
For coding agents: ion-drive mcp serves a stdio MCP server with four
registry tools — search_blocks, get_block (version history + README),
list_registries, and preview_install (the same resolve + digest/trust
verification pipeline as ion-drive add, returning the plan and trust
verdicts without ever making changes). This composes with the platform’s own
MCP surface at /api/v1/mcp: use the CLI MCP to choose blocks, then the
server MCP to work with installed data.
Installing with the CLI
Section titled “Installing with the CLI”npx ion-drive list # the default registry's catalognpx ion-drive list --all # every configured registrynpx ion-drive add crm # bare ref → the default registry's latestnpx ion-drive add crm@0.1.0 # exact versionnpx ion-drive add crm@^0.2 # semver range (highest satisfying wins)npx ion-drive add @acme/billing@1.x # namespaced ref → the @acme registrynpx ion-drive add https://…/block.json # direct URLnpx ion-drive add ../blocks/invoicing # local path (the block-dev loop)npx ion-drive remove invoicing # uninstall (your vendored code stays)npx ion-drive registry list # configured registries + stalenessUseful flags:
--dry-run— preview the changes without applying them.--force— reinstall even if already present (idempotent: existing objects, relationships, and files are skipped; ledger ownership is preserved), and proceed through installed-version range conflicts.--no-cache— bypass the 5-minute registry metadata cache.remove --drop-data— also drop tables that still have rows.
Each install is recorded in ion.config.json’s blocks[]
(name/version/digest/source/sourceUrl/installedAt) — the
project’s lockfile-equivalent.
For a block with vendored code, add waits for your dev server (tsx watch) to
reload the new handlers before installing the manifest — so run it with
npm run dev active. remove uninstalls the schema and unwires the barrel,
but never deletes blocks/<name>/ — that code is yours now.
Updating blocks
Section titled “Updating blocks”ion-drive diff and ion-drive update (spec-07) move an installed block to a
newer version without ever violating the ownership contract: the vendored
code is yours, and no tool auto-overwrites or deletes it.
ion-drive diff crm # what would updating change?ion-drive diff crm --version 0.4.0 # against a specific version (or a range)ion-drive update crm # apply it (diff + confirm first)ion-drive update crm --with-deps # perform required dependency updates tooion-drive update crm --force # also apply destructive manifest changesion-drive update crm --force --drop-data # …even when removed objects hold rowsBoth commands compare three inputs: the server ledger’s pristine manifest
snapshot (what was installed), the new digest-verified artifact from the
block’s recorded source registry, and your working tree under blocks/<name>/.
Blocks installed from a local path or direct URL have no registry to update
from — re-install those with ion-drive add <path-or-url> --force.
Manifest changes apply server-side through the validated schema pipeline
(POST /api/v1/blocks/install?upgrade=true): additive changes just apply,
modifying changes ride modifyField’s preview machinery (a field that becomes
required backfills existing NULLs from its own defaultValue; without one the
step fails actionably and the upgrade is safely re-runnable), and
destructive changes (removed objects/fields/relationships/tasks) are
skipped and reported by default. Items the block owned that it no longer
declares are released to user management — they are yours now, like the
vendored code. --force applies destructive changes instead, preview-first
with a final confirmation (--drop-data per the usual uninstall rule).
Downgrades are refused; the recovery is ion-drive remove then
ion-drive add <name>@<version>.
Code changes honor a per-file, byte-compared verdict against the ledger
snapshot: pristine files are safely overwritten, files you modified get the
new version written beside them as <file>.new — loud in git status on
purpose; merge what you want and delete the .new file. Files removed
upstream are listed but never deleted; files you created are never touched.
The equal-version re-POST is a no-op (200); dependency-range violations refuse
with the ordered plan (--with-deps performs the chain, dependencies first).
Integrity and trust
Section titled “Integrity and trust”Every registry install is digest-verified (spec-04): the CLI computes
sha256:<hex> over the exact fetched artifact bytes and compares it with the
registry-declared digest for that version before anything is parsed,
vendored, or sent to the server. A mismatch aborts the whole plan with no
--force override — a poisoned artifact is never “forced”. Direct-URL
installs have no declared digest; the computed one is printed once so you can
pin it. Local paths hash the bytes the CLI itself packs. The verified digest
is recorded in ion.config.json and in the server’s _ion_blocks ledger.
On top of digest integrity sits provenance: publishers can attach a
sigstore attestation bundle produced by GitHub’s
artifact attestations. The CLI computes one of three trust tiers — never
taken from the registry’s self-asserted trust field, which is a display
hint only (shown as “(claimed)” in ion-drive list):
| Tier | Badge | Meaning |
|---|---|---|
official | ◆ official | Attestation verified AND built from jaredgrabill/ion-drive-blocks |
verified | ✔ verified · github.com/acme/blocks | Attestation verified against the repo the registry claims |
community | ○ community (unattested) | No bundle, failed/unavailable verification, or local/URL source |
Attestation proves where the code was built, not that it is safe. A
verified badge means “these exact bytes were built by that repository’s CI” —
review the vendored code regardless (it lands readable in your tree;
add --show-code lists every file with its size and sha256 before the
confirm prompt). An absent bundle is a warning; a present-but-invalid
bundle is a loud warning (it can indicate tampering) — add still proceeds
because the digest already protects integrity, while ion-drive block verify
exits non-zero. --no-verify-provenance skips attestation checks (e.g.
offline environments, which otherwise degrade to community with a warning);
the digest check is never skippable.
Audit any published block without installing it:
ion-drive block verify crm@0.2.0 # digest + attestation + tier verdiction-drive block verify crm --against-installed # ledger digest vs registry digestion-drive block verify crm --json # machine-readable verdict--against-installed catches “the registry mutated after I installed” and
“someone installed different bytes on this server” by comparing the server
ledger’s recorded digest with the registry’s for the installed version.
Installing over HTTP
Section titled “Installing over HTTP”Blocks are also a plain REST surface under /api/v1/blocks (RBAC resource
blocks, gated by ION_BLOCKS_ENABLED):
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/blocks | List installed blocks (the _ion_blocks ledger) |
GET | /api/v1/blocks/:name | Inspect one installed block |
GET | /api/v1/blocks/actions | Declared actions/hooks + registered handlers |
POST | /api/v1/blocks/preview | Validate + preview a manifest |
POST | /api/v1/blocks/install | Install a manifest (?dryRun, ?force; body is a bare manifest or { manifest, source }) |
POST | /api/v1/blocks/:block/actions/:action | Invoke a block action |
DELETE | /api/v1/blocks/:name | Uninstall (?dropData) |
Installation is step-wise and idempotent-friendly — existing objects are skipped and reported rather than duplicated — and dependency, requirement, and data-loss guards are enforced server-side, not just in the CLI:
- A missing dependency fails
422(code: DEPENDENCY); a dependency installed at a version outside the declared range fails422withcode: DEPENDENCY_VERSION, naming the installed version and the required range. - An unsatisfied
requires.corefails400, naming the running core version and the declared range. ?force=truedowngrades both range failures to warnings in the install report (the ADR-017 force contract);?dryRun=truereports them as warnings without failing.- The optional
sourceenvelope member is client-asserted provenance (registry, artifact URL, verified digest, attested flag, publisher, tier) stored in the_ion_blocksledger and returned by theGETendpoints — audit metadata for incident response, not a server-side security control. Unknownsourcekeys fail400.
Actions and inbound webhooks are documented in Actions & hooks.
Authoring a block
Section titled “Authoring a block”Scaffold the standard layout, validate against the platform’s Zod schema, and pack the distributable artifact:
ion-drive block new my-block # ./block-my-block/{block.json, code/, CI}cd block-my-block# …edit block.json (+ code/ if the block ships logic)…ion-drive block validate # platform schema + structural code checksion-drive block pack # dist/<version>/block.json with code/ embeddedblock-my-block/ block.json # the manifest — source of truth code/ # vendored TypeScript (index.ts default-exports a definePlugin) dist/<version>/block.json # immutable packed artifact — what a registry servesTest against a real project without publishing anything:
cd ../my-app && ion-drive add ../block-my-blockA block that declares actions/hooks must ship code/ whose plugin
registers matching handlers (ctx.actions.registerAction/registerHook) —
install fails with an actionable error otherwise. Keep vendored code thin
and heavily commented: call DataService/SecretsManager/platform APIs,
never re-implement plumbing. LLM legibility is a product goal.
Testing a block
Section titled “Testing a block”ion-drive block test is the install-and-run loop nothing else covers
(spec-06): it boots a real ephemeral Ion Drive server — a throwaway temp
project with your block’s code vendored, on a scratch database created for
the run — installs the block for real, asserts, and tears everything down:
ion-drive block test [dir] # ephemeral mode (the default) --database-url <dsn> # Postgres connection point # (or ION_DATABASE_URL; the scratch # ion_blocktest_* DB is created/dropped) --deps-from <dir> # resolve deps from local sibling # block directories — offline, the # co-developed/monorepo loop --server <url> # run against an existing server --keep --force --json --no-cacheThe built-in assertion suite checks that: the manifest parses (core’s strict
schema), dependencies install, the install report is clean (everything
declared — objects, relationships, tasks, roles, subscriptions, webhooks,
actions, hooks — was created or explainably skipped), every object answers
GET /api/v1/data/<object> with at least its seeded rows, every action is
reachable at its endpoint (a 400 from the action’s own validation counts
as “wired” for a blank probe — the check is reachability, not business
correctness), and uninstall leaves zero residue (ledger empty, schema
doctor finds no orphan tables in the block’s footprint).
If the block has a test/ directory, its *.test.ts files then run under
tsx --test (plain node:test — zero framework lock-in) with two env vars:
ION_TEST_SERVER_URL and ION_TEST_API_KEY (an admin-role key minted for the
run). An optional test/fixtures.json feeds the built-in suite:
{ "actions": { "create_thing": { "input": { "name": "x" }, "expectStatus": 200 } }, "seedChecks": { "things": 3 }}--server <url> skips the ephemeral boot and runs the same loop against a
running server — the CI-service-container mode, and the fastest inner loop
against ion-drive dev. It refuses a server that reports existing user
objects unless --force, and finally-guards its uninstalls so even a failing
run leaves no residue. Dependencies resolve through your configured registries
by default (the spec-04 digest gate applies) — --deps-from keeps everything
local. The scaffolded block CI (block new) runs
validate → pack → block test → dist drift guard against a Postgres service
container; a green block test is required before a block enters the official
registry.
Auditing installs
Section titled “Auditing installs”ion-drive audit is the ecosystem’s Dependabot-lite: it reads your project’s
installed-block records (ion.config.json.blocks[]) and checks each
registry-sourced block against its registry’s current metadata:
- advisories whose
affectedVersionsrange matches your installed version (an invalid range fails closed — treated as affected, with a warning); - status — your installed version is now
yankedordeprecated; - digest drift — the registry now serves a different digest for your installed version (a released version was mutated — loud);
- ledger drift — when your server is reachable, its
_ion_blocksledger must still match the config record (version + artifact digest); an unreachable server degrades to a config-only audit with one notice; - updates — a newer active version exists (informational only).
Exit codes are CI-friendly: 0 clean, 1 on any advisory/yank/deprecation/
drift finding; --json emits the stable { clean, blocks, unauditable, notices } shape. Local-path and URL-sourced blocks are listed as
“unauditable source” (no registry can vouch for them) — informational, never
a failure. Projects scaffolded by ion-drive init run ion-drive audit in
CI on every push and on a weekly schedule, because advisories and yanks
land on the registry side while your repo sits still.
Publishing a block
Section titled “Publishing a block”A registry is a git repo of block directories plus generated protocol-v1 JSON — GitHub Pages/S3/nginx serve it; no server required. Two commands run the whole publish side (spec-05):
ion-drive registry build [dir] # the generator: validate every */block.json, # pack missing dist/<version>/ artifacts, # regenerate registry/blocks/*.json + index.jsonion-drive registry build --check # CI drift guard: fails on any would-be changeion-drive block publish # clone a registry repo → copy this block in → # build there → open a PR (--direct pushes)The generator is append-only: released (name, version) artifacts and
version entries are immutable — any mutation is a named refusal, and fixing
anything means bumping the version. A registry.config.json at the registry
repo’s root supplies the registry’s identity (name, plus the repository
stamped on every block doc — the claim attestations are verified against).
block publish reads its default target repo from
meta.publishConfig.registryRepo in block.json, or takes
--registry-repo <owner/repo | git URL | local path>.
Provenance comes from CI, not from your laptop: the official repo’s reusable
workflow (publish-block.yml) packs new versions on merge to main, attests
each artifact with GitHub artifact attestations (sigstore — that’s what makes
ion-drive add show ◆ official / ✔ verified), and commits the result.
Publishing locally cannot attest provenance — a locally-pushed version is
community until the registry repo’s CI attests it, the same incentive
structure npm uses.
Mutable-status administration (in a registry checkout, then commit + PR):
ion-drive registry yank crm@0.2.0 --reason "corrupts pipeline stages"ion-drive registry deprecate crm@0.1.0 --reason "superseded by 0.2.0"Yanked versions are never selected by resolvers (exact re-installs of a
version recorded in your project keep working, loudly warned); deprecated
versions install with a warning. The official repo’s full operating procedures
live in its docs/registry-operations.md.
See ADR-013, ADR-018, and ADR-022 for the design rationale.