Writing a block
This is a hands-on walkthrough of building a small block from scratch: scaffolding it, validating it, packing it, approving it and editing it, plus the design choices that make a block pleasant to use once it's in a document. The plicine command it uses comes inside the app: Plicine > Install Command-Line Tool… puts it in your terminal. The examples come from three blocks used in these docs: quote-table, a priced table, iso, a diagram you can turn, and note.
Scaffold
plicine create-block status-note --scope @acme --description "A short status callout."
Created status-note
manifest.json
package.json
tsconfig.json
BLOCK.md
src/index.tsx
Next:
1. Edit src/index.tsx (schema, Interactive, Print) and the example in BLOCK.md
2. plicine validate status-note
3. plicine pack status-note --into <vault>
This creates a small, complete starting point: a manifest with a 0.1.0 version and no capabilities, a package.json with react and zod as peer dependencies (never bundled, see below), and a src/index.tsx with a working schema, Interactive and Print you can shape into whatever the block actually needs. --scope sets the organisation prefix on the block's id (@acme/status-note); without it, blocks default to @local.
Validate
plicine validate status-note
Checked @acme/status-note@0.1.0 (77ae98f3d59b), 1 example: 0 errors, 0 warnings
validate on a block folder compiles it, runs every fenced example in BLOCK.md whose name matches the block's alias through the schema, and renders both Interactive and Print with that data. A render that throws is an error; Print output that differs on a second pass with the same props is a warning. This is why the scaffolded BLOCK.md already has one example: without at least one, there's nothing for validate to check, and pack warns about it too.
Pack
plicine pack status-note --into demo-vault
Packed @acme/status-note@0.1.0 into demo-vault/.vault/blocks/status-note.block (77ae98f3d59b, 2.0 KB)
pack zips manifest.json, BLOCK.md, package.json, bun.lock (if present) and everything under src/ into a single .block file, written into <vault>/.vault/blocks/. Drop that file into a shared vault and everyone with it syncs the block along with the documents, no npm registry or GitHub involved.
Approve and edit
The first time a document in that vault uses status-note, the app (or plicine trust) asks for approval before it runs, exactly like any other block; see Why a block asks before it runs. Once approved, hovering the block in the infinite layout and choosing Edit opens a form built from its schema, and editing that form rewrites only the YAML lines that changed. Writing and editing covers that editing flow in full.
Designing a schema that makes a good form
The schema isn't just validation, it's also where the edit form comes from, so how you write it shapes what the person filling it in sees:
.describe("…")becomes the help text shown under a field in the form. Write it from the reader's point of view: thequote-tableblock's row schema usesqty: z.number().min(0).default(1).describe("Quantity"), so the form shows a clear label and hint even though the field name is terse..meta({ title: "…" })overrides the field's label. Without it, the form derives a label from the property name; with it, you can give a field a label that reads naturally,quote-tabledoes this forvat, giving it the label "VAT" instead of the derived "Vat".- Defaults matter more than they would in a typical form. A field with
.default(...)shows that default as placeholder text rather than a value, and stays out of the saved YAML until the person actually sets it, so a document's YAML only ever records what someone chose to say, not every default value repeated everywhere. - This is exactly why
io: "input"matters: Plicine builds the form's JSON Schema from a block's schema with Zod'sio: "input"option, which describes the schema's input shape, before any defaults are applied, rather than its parsed output shape. A field with.default(...)stays optional in the form (and in the JSON Schema) instead of becoming required with that default baked in, which is what lets the form show it as a placeholder rather than a value. - Offer names before numbers. Someone who has never heard of CSS should be able to set every field, so a size, a spacing or anything else with a technical value gets named choices, with the exact value as a second way in.
z.union([z.enum(["none", "small", "medium", "large"]), z.string()])builds exactly that: a row of buttons like the width's, one per name, written as "None", "Small" and so on, with Auto in front when the field is optional and Custom at the end, which opens a text box for the string. For spacing,.meta({ presetIcons: "row-gap" })or"column-gap"draws the names as pictures of two boxes with growing room between them, the first name the least room and the last the most. Describe the string branch in plain words (.describe("An exact size: a number with its unit, e.g. 12px or 6mm")), since that's the help shown under the box. The corecolumnsblock's Row spacing and Column spacing work this way. - Stick to types
z.toJSONSchemacan represent. Objects, arrays, strings, numbers, booleans, enums,.optional()and.default(), nested combinations of all of those, all convert cleanly, and that's most of what a block needs. A schema that can't be converted (custom classes, functions, anything JSON Schema has no shape for) still works for validation, but the app can't build a form for it, and tells the person to edit it as markdown instead;validatewarns about this too, so you'll see it before anyone else does.
Interactive vs Print
Interactive is what runs in the app's infinite layout: it can use hooks, state, effects, event handlers, anything React normally allows, the iso block's Interactive keeps the currently hovered item and camera angle in useState.
It runs in an iframe as wide as the block and exactly as tall as its content, and nothing it draws can leave that box. How wide the block is belongs to the person writing the document: the text column, or wider with {width="wide"} or {width="full"} on the fence, set from the block's toolbar. So lay a block out to 100% of the width it's given, in Print as well as Interactive, and don't add a width prop of your own or draw at a fixed pixel size, which would leave the setting doing nothing. A chart drawn as SVG with a viewBox, width: 100% and height: auto does this with no measuring; A Gantt chart block drawn this way gets wider when the person sets it to wide, with nothing else to do. A tooltip, popover or menu the block draws itself is clipped at the frame's edge whatever its z-index or position: fixed says, so it has to fit inside the block, flipping or clamping near the edges. The tooltip for a title attribute, or an SVG <title>, is the exception: the browser draws it outside the page. The .block package has the rest of what the frame allows.
Print has one job: given the same props, always render the same static HTML, with no hooks that hold state across renders and no reliance on anything but its own props. It's rendered with renderToStaticMarkup, not mounted into a live page, so effects never run anyway, but the design constraint is worth keeping in mind even so: the iso block's Print renders the diagram once, at a fixed camera angle taken from props, with no rotate controls at all. plicine validate checks the determinism half of this directly, by rendering Print twice with the same data and warning if the two outputs differ.
Styling
Blocks render inside their own sandboxed frame (for Interactive) or get their HTML inlined into the document (for Print), so there's no shared stylesheet to lean on. Use the theme's --fx-* custom properties with a sensible fallback, so the block still looks reasonable even outside a themed vault:
background: "var(--fx-color-surface, #f6f6f4)",
borderRadius: "var(--fx-radius, 6px)",
Inline style objects go a long way: quote-table builds a small s object of reusable style fragments and uses no stylesheet at all. A block that does need a <style> tag in its Print output can use one; Plicine scopes it to that block's own output automatically when inlining it into the printed page.
Dependencies and bun.lock
react and zod are provided by the host at runtime, so list them as peerDependencies (the scaffolded package.json does). If they appear under dependencies they're ignored: the host's copies are used and nothing is installed for them. Anything else your block genuinely needs goes under dependencies, and needs a bun.lock alongside it: run bun install inside the block folder after adding a dependency so the lockfile exists and matches. That's the one step that needs Bun installed; a block with no dependencies of its own needs nothing but the plicine command. Dependencies are installed at compile time with --frozen-lockfile --production --ignore-scripts, so nothing's install script runs, and imports that try to reach outside the block's own package are refused at compile time.
Text that's edited on the block
A note, a card, a testimonial: when a prop is mostly prose, don't draw the string yourself. Give it to Markdown from the plicine module, and the person edits it on the block, with the editor the rest of the page has:
import { Markdown } from "plicine";
export const schema = z.object({
text: z.string().meta({ contentMediaType: "text/markdown" }).describe("Note body, as markdown"),
});
function Note() {
return (
<div className="note">
<Markdown field="text" placeholder="Write the note" />
</div>
);
}
field names the prop, and that's where an edit is saved. For one line that sits inside a line of your own, a title or a bold lead-in, add inline: <Markdown field="title" inline /> renders a <span> and Enter saves. Give every prop a person reads as text one of the two, so nothing on the block can only be changed from the form. Draw the field whether or not the prop is set, not behind a {title && …}: an empty field renders nothing, on screen or in print, until the block is being edited: open any of its fields and the empty ones appear as their placeholder, faint, to be clicked and filled in. (When every field is empty they show anyway, or there'd be nothing to click.) Hide it behind a condition and a title someone deletes can only come back through the form. The same component serves both renders: on screen a click opens the editor, in print it's the rendered markdown. The contentMediaType meta is separate and worth having too, since it gives the prop a markdown editor in the block's form.
Style what's inside the field from the block (.note p { margin: 0.4em 0 } in a <style> tag or an imported CSS file). The theme's document styles stop at the frame, and the editor copies whatever look the block gives the text, so the two can't drift apart. The note block does this: its lead-in runs into the first paragraph, which takes a few lines of CSS.
Buttons in the block's toolbar
useToolbar adds buttons of the block's own next to the width control, and update writes props back into the fence:
import { update, useToolbar } from "plicine";
export function Interactive(props: Props) {
useToolbar([{ id: "tone", label: `Tone: ${props.tone}`, run: () => update({ tone: next(props.tone) }) }]);
return <Note {...props} />;
}
Call the hook on every render of Interactive, never in Print. Keep to a few short labels (six at most are shown), and use them for what the form makes slow: switching a style, adding a row. update takes top-level props, removes one given as null, and throws if the result wouldn't pass the schema. A block can only ever write its own fence.
Storage
A block that needs to remember something between visits can declare "capabilities": { "storage": true } in its manifest. Once a person has approved that exact content with storage allowed, the block can call:
await globalThis.plicine.storage.get("key");
await globalThis.plicine.storage.set("key", value);
Data is kept per block, per vault, in the app's own data folder, not written into the vault, see Vaults for why generated data never goes into a shared folder.
Versioning and trust
Approval is keyed to a block's exact packaged content, not its declared version, so bumping the version in manifest.json (which changes the packaged bytes) always produces a new hash and a new approval prompt, and so does any change to the code with no version bump at all. There's no way to silently update an approved block's behaviour; every change, however small, is pending again until someone looks at it. Bump the version anyway, it's how anyone using a fixed version range in their frontmatter pin (@acme/status-note@^1) or reading blocks.lock can tell what actually changed.
Editing an installed block
A block that's already in a vault, whether you wrote it or it came from someone else's .vault/blocks/, doesn't have to be edited in place inside that folder. plicine block checkout extracts it into an ordinary source folder outside the vault, so it's a normal edit-with-your-editor, run-your-usual-tools loop, and plicine block push writes the result back over the original .block file, superseding it exactly the way replacing that file by hand would.
plicine block checkout quote-table my-vault
Checked out @solunify/quote-table@2.1.0 into /Users/you/Library/Application Support/plicine/dev/20c6593f1ded5d4d/quote-table
Next:
1. Edit the files in .../quote-table
2. plicine validate .../quote-table
3. plicine block push --dir .../quote-table
Edit src/index.tsx, BLOCK.md, the schema, whatever needs changing, the same way you would in a fresh create-block folder. When it's ready:
plicine block push --dir .../quote-table
push runs the same checks validate does first, compiling the block and running its BLOCK.md examples, and only writes to the vault if that passes. If someone else changed the vault's copy since you checked it out (or since your last push), push refuses and names both hashes, rather than silently overwriting; --force overwrites anyway, and plicine block diff/status (below) help sort out what changed on either side first.
For a tighter loop, plicine block edit quote-table my-vault does checkout then re-validates and re-pushes automatically on every save, printing what happened after each one, until Ctrl+C.
plicine block status --dir .../quote-table # local edits pending? has the vault's copy moved?
plicine block diff --dir .../quote-table # what changed, file by file
plicine block discard --dir .../quote-table --yes # done editing; delete the checkout
Approval carries forward, but only when it's safe to
Each push is a new content hash, so by the rule above it would normally need a fresh approval before it runs anywhere. plicine block push/edit carry the previous approval forward automatically, without a prompt, in exactly one case: the block's id was already approved and the new version declares the same network/storage capabilities as that approval (including a first push that declares neither). Any capability change, or a hash with no prior approval to carry forward, is left pending exactly like a hand-edited .block file dropped into the vault would be, and push says so. This is what makes the edit-and-push loop fast without weakening what approval means: the person pushing is the person who just looked at the diff, but a block that starts asking for storage or network for the first time still needs a real look.
See the CLI reference for every flag, and Using AI agents for why this loop, with --json on every subcommand, works as well for an agent as for a person at a terminal.
BLOCK.md is more than documentation
BLOCK.md doubles as the block's test fixtures and as the primer an AI agent reads before using it: validate runs every example fence named after the block through the schema and both renders, and pack warns if there isn't at least one, "agents rely on it as a few-shot sample". Keep its examples real and varied enough to show the shapes the schema actually accepts, the iso block's BLOCK.md deliberately includes one example with a broken link id, to show that a dangling reference is reported rather than crashing the render. Using AI agents covers this from the agent's side.
A worked example
Here's one of quote-table's own examples, a priced quote with an optional extra:
```quote-table
currency: GBP
vat: 0.20
rows:
- { item: Discovery workshop, qty: 1, unit: 1200 }
- { item: Build, qty: 12, unit: 650, note: days }
- { item: Support retainer, qty: 3, unit: 400, note: months, optional: true }
terms: 50% on signature, balance on delivery. Valid for 30 days.
```
And here it is, rendered live on this page by the same quote-table block packed into this vault's .vault/blocks/ folder:
| Item | Qty | Unit | Amount |
|---|---|---|---|
| Discovery workshop | 1 | £1,200.00 | £1,200.00 |
| Build | 12 days | £650.00 | £7,800.00 |
| Subtotal | £9,000.00 | ||
| VAT (20%) | £1,800.00 | ||
| Total inc. VAT | £10,800.00 | ||
Optional extras
| Support retainer | 3 months | £400.00 | £1,200.00 |
50% on signature, balance on delivery. Valid for 30 days.