The .block package
What a .block file contains, the module contract it must export, what capabilities grant, and exactly what the compiler refuses.
A .block is a zip file. plicine pack <dir> builds one from a source folder; you can also build one by hand as long as the layout below is right.
Layout
| Path | Required | Meaning |
|---|---|---|
manifest.json |
yes | Identity, version and capabilities (schema below) |
BLOCK.md |
yes | Human- and agent-readable docs, with at least one fenced example |
package.json |
yes | npm-style manifest; only dependencies is used (see below) |
bun.lock |
only if dependencies is non-empty |
Lockfile for those dependencies |
src/... |
yes | Source, entered at the manifest's entry (default src/index.tsx) |
plicine pack only ever includes manifest.json, BLOCK.md, package.json, bun.lock (if present) and everything under src/; anything else in the source folder is left out of the archive. Loading a .block (readBlockFile) only requires manifest.json, BLOCK.md and package.json to be present and the manifest's entry file to exist; a hand-built archive can carry extra files (fonts, sample data) as long as entry only imports files inside the package.
manifest.json
| Field | Type | Default | Rule |
|---|---|---|---|
id |
string | required | Must match /^@[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/, e.g. @acme/price-grid. The @core scope is reserved for the blocks the app ships with and refused |
alias |
string | required | Must match /^[a-z][a-z0-9-]*$/; the short name used as a fence's info string |
version |
string | required | Semver: /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ |
description |
string | none | Shown in the trust prompt and the block's toolbar |
entry |
string | "src/index.tsx" |
Module the compiler bundles |
capabilities.network |
boolean | false |
See Capabilities below |
capabilities.storage |
boolean | false |
See Capabilities below |
migrations |
object of string to string | {} |
Parsed but not read by anything yet |
{
"id": "@solunify/quote-table",
"alias": "quote-table",
"version": "2.1.0",
"description": "Priced line items with quantities, optional extras, discount, VAT and totals.",
"entry": "src/index.tsx",
"capabilities": { "network": false, "storage": false }
}
package.json rules
Only the dependencies field is acted on. Anything listed there other than react, react-dom, react/jsx-runtime, react/jsx-dev-runtime, zod, zod/v4 or plicine (the modules the host provides, see below) counts as a real dependency that needs installing, and packing or loading the block then requires a bun.lock next to it; without one, readBlockFile/packBlockDir fails with package.json declares dependencies but bun.lock is missing. peerDependencies and devDependencies are conventional npm fields the scaffold fills in for editor tooling, but nothing in the compiler reads them.
Real dependencies are installed once per exact (package.json, bun.lock) pair, keyed by their combined hash, with:
bun install --frozen-lockfile --production --ignore-scripts
and the resulting node_modules is symlinked into the extracted block. --ignore-scripts means a dependency's own install/postinstall scripts never run.
Module contract
The entry module can export:
export const schema: z.ZodType; // validates props, and generates the edit form
export function Interactive(props): JSX.Element; // screen render: state, effects allowed
export function Print(props): JSX.Element; // print render: static and deterministic
export default function AnyComponent(props): JSX.Element; // fallback for any of the above
At load time, each of schema, Interactive and Print falls back to the module's default export if it isn't exported directly; if there's a default but no schema, the schema defaults to z.looseObject({}) (any object passes). If, after fallback, any of the three is still missing, the block fails to load with Block module is missing required exports: <list>, which is what you'll see as the block's error state in both the app and plicine validate.
react and zod (and react-dom, react/jsx-runtime, react/jsx-dev-runtime, zod/v4, and Plicine's own plicine module, below) must never be bundled: importing them resolves, at compile time, to the copies the sandbox runtime already has loaded as globals (globalThis.__plicine.modules), via a Bun build plugin. Bundling your own copy isn't possible for these names; they're intercepted before Bun's resolver ever looks at node_modules.
Print is expected to render the same markup for the same props every time: plicine validate renders it twice per BLOCK.md example and warns if the two outputs differ.
The plicine module
import { Markdown, update, useToolbar } from "plicine";
Provided when the block runs, like react. Its types are a declaration file the scaffold writes to src/plicine.d.ts; copy it from a newly made block into an older one.
| Export | What it does |
|---|---|
<Markdown field="text" /> |
Renders the string prop text as markdown, by the same renderer the document uses. In Interactive, a click on it opens the editor the page's own text has, inside the block: markup shown at the caret, a copy that takes what's rendered, Copy as markdown on a right-click, Esc to cancel, Cmd/Ctrl+Enter or a click elsewhere to save. What's written is saved into that prop. In Print, and in plicine validate, it's the rendered markdown only. Also takes className, style and placeholder (what an empty field shows on screen). field can be a path: ["items", 2, "text"]. With inline it's one line in a line of the block's own, a title or a lead-in: a <span> with no paragraph around it, where Enter saves, and a box of its own only while it's edited. A field saved empty takes its prop out when the schema allows that, and leaves it empty otherwise. An empty field is nothing in Print, and nothing on screen either until the block is being edited, which is while any of its fields has its editor open; then it's its placeholder, there to be filled in. When all of a block's fields are empty they show regardless. So draw a field unconditionally |
useToolbar(items) |
Adds the block's own buttons to its toolbar, beside the width control and Markdown. Call it on every render of Interactive with { id, label, run } items; title, active and disabled are optional. At most six are shown. Pressing one calls run from the latest render |
update(props) |
Writes top-level props into the block's fence; a prop given as null is taken out. It throws, and writes nothing, when the block's props would no longer match schema. It does nothing in Print |
On the pages the block isn't running: what's there is its Print output, in which each field carries its path (data-fx-field), and a click on one opens the page's own editor over it, saved into the same prop after the block's schema has passed it. A block's styles for a field's is-editing class apply there too. An empty field can't be reached on the pages, since Print draws nothing for it.
The field's editor has no / menu and takes no pasted images, because a block in a frame can't hold the app's blocks or reach the vault. Links in the field don't open from inside the frame. None of the module works in a ts-block, whose fence is code rather than props.
The theme's document styles don't reach the frame, so what's inside a Markdown field looks the way the block's own CSS makes it look, and the editor takes its type from that. The frame only zeroes the top margin of the field's first element and the bottom margin of its last.
Capabilities
Declaring a capability doesn't grant it by itself: the block must also be approved by content hash on the machine running it (Why a block asks before it runs covers approval; this section covers what each capability actually changes once approved).
| Capability | Grants |
|---|---|
network |
The block's sandboxed iframe gets https: added to its img-src and connect-src in the frame's Content-Security-Policy, so fetch() and remote images work over HTTPS. Without it, both are blocked entirely (connect-src 'none'). Plain HTTP is never allowed either way. |
storage |
The block may call globalThis.plicine.storage.get/.set (see below). Without it, the host's storage route refuses every request from that block's content hash with this block may not use storage, whether or not the block calls the API. |
globalThis.plicine.storage
Available to every block regardless of capability; whether calls succeed depends on the capability and approval above.
declare const plicine: {
storage: {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
};
};
Both calls go over the sandbox's private channel to the app, which keys the stored JSON object by a hash of the block's id (not its content hash, so an update to the block keeps its data) inside the vault's own cache folder: The app data folder has the exact path. Data is per block id per vault; two vaults, or two different blocks, never share storage.
When a block fails
- A crash inside
Interactiveis caught in the frame. The block's place on the page shows a red error box with the message, and the rest of the document carries on. - A crash inside
Print, or props that don't pass the schema, is reported for that one render: as a warning on the export, and as a problem fromplicine validate. - A block gets 20 seconds to load for a print or a validation. After that the render fails with
The block took too long to load. On screen there's no timeout: a block that never finishes loading shows nothing. updatefrom a block is ignored while that block's form is open, since the form's draft would write over it, and refused when the fence changed on disk meanwhile. Ats-blockhas no props, so anupdatefrom one is dropped.- A storage call from a block that wasn't approved with
storagerejects withThis block did not ask for storage.
What's refused at compile time
Both a packaged block and an inline ts-block (see Core blocks) go through the same bundling step, which refuses two things before Bun even runs:
- Bun macros. Any source file in the package whose text matches
type: "macro"(a Bun import attribute) fails the build with<file>: bundle-time macros are not allowed in blocks. Macros run arbitrary code on the machine building the block, which is exactly what the sandbox exists to avoid. - Imports outside the package. A
build.onResolveplugin resolves every import and checks whether its real path (after following symlinks) sits inside the block's own extracted folder or its installed dependencies; anything else, such as a relative import that walks out ofsrc/with../../, fails withimport of "<path>" resolves outside the block package.
Bundling itself uses whitespace and identifier minification only, not syntax minification, because Bun 1.3's syntax minifier can produce invalid output for some libraries.
Content hashes and deterministic packing
A package's identity for trust and caching purposes is contentHash(files): every file's path and the SHA-256 of its bytes, sorted by path, hashed again. Because it hashes contents rather than the archive's bytes, re-zipping the same files with a different tool, or on a different machine, doesn't change the hash or reset approval. Any change to any file, including manifest.json, produces a new hash and needs a fresh approval.
plicine pack builds the zip itself deterministically: entries are sorted by name, every entry gets a fixed modification time (2 January 1980, built from local date parts so it's the same across time zones), and compression is fixed at level 9. Two machines packing the same source folder therefore produce byte-identical .block files, not just files with the same content hash.
Unpacking refuses zip slip paths (entries starting with /, containing \ or \0, a Windows drive letter, or a .. segment) and caps a single archive's uncompressed size at 64 MB.