Skip to the document

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 Interactive is 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 from plicine 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.
  • update from 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. A ts-block has no props, so an update from one is dropped.
  • A storage call from a block that wasn't approved with storage rejects with This 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.onResolve plugin 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 of src/ with ../../, fails with import 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.