npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@meldly/module-sdk

v0.3.12

Published

Build a module for Meldly: the bridge between a module's page and the Meldly app hosting it, and the typed API each side offers the other.

Readme

@meldly/module-sdk

Geometric editor context

DocumentSelection and pixelSelectionContext publish bounded object, frame, layer and exact sparse pixel intent through the existing presence door. The host pins it to the frame document; source access is checked when agents read it. This is ephemeral UI context, not content or authorization. See GUIDE.md.

Document format migrations

document.format declares persisted data compatibility independently of release versions. @meldly/module-sdk/document-migrations plans bounded JSON transitions; jsonMigrationAdapter uses the reserved $format body field. The optional @meldly/module-sdk/yjs-migrations entry plans on a disposable Y.Doc, checks the complete baseline before commit and marks retained roots as immutable archives. Neither helper writes on read, installs updates or performs network I/O. Persist the plan through an ordinary declared operation with a conditional inverse. See GUIDE.md, “Versioned documents”; migrations do not run automatically.

Server and worker batches that already own a disposable document can call migrateDocumentDraft or migrateYjsDraft to avoid cloning it again. These use the same path, validation and limits. Failure can leave the private draft partly changed: discard it whole and publish nothing. They are not live-document APIs.

decodeYjsDraft(encodedState) creates a complete disposable Y.Doc and starts byte bookkeeping from its received bytes. yjsByteBound(doc, limit) returns a conservative V1 encoded-size bound, serializing exactly when needed near the limit or during unsettled transactions. Migrations reuse it automatically. Stored JSON/byte values must not be mutated in place; use Yjs writes, as required for synchronization. The counter owns no persistence, source buffer or history.

Optional Yjs apply.summary carries a bounded recognized summary of the final private state; the host reuses it only under its producing-brain and exact snapshot guard. Source staging, CAS merges and a different current history brain use the ordinary summary fallback. Browser saves accept no supplied summary. The dev host validates the same field. See GUIDE.md and RUNTIME.md for the contract.

Image view presence can include a bounded palette-to-colorway ID map for Follow. It is window-local view intent and never a document mutation or access grant. Active line, rectangle and ellipse pointerTool presence can also carry preview endpoints (from/to, each an [x,y] pair in cursor content coordinates). projectToolFootprint renders their contour through the same affine projector, including a zero-width vertical line. Clear it on release or cancellation; it is ephemeral intent, never pixel data or a saved operation.

Build-time icon weights

@meldly/module-sdk/icon-weights exports the shared Vite build plugin used by the app and the public UI kit. It keeps regular Phosphor icons plus additional weights named in the supplied source roots, and refuses an unreadable dynamic weight instead of shipping a blank icon. Use iconWeights({sources:['src']}) in the Vite config; sources are relative to Vite's root. The helper is Node-only and adds no browser runtime or dependency to the base SDK. Prebuilt shared UI keeps its own required weights; callers still own icons they bundle themselves.

Static spatial queries

@meldly/module-sdk/spatial-index exposes the core's static rectangle index: buildSpatialIndex, querySpatialIndex and allocation-free spatialPoint, plus their metadata types. Use it for viewport/crop admission; it owns no source data, decoding, camera or rendering lifecycle. Rebuild when captured geometry changes. Query edges are inclusive; point membership is half-open.

Binary document source contract (under development)

@meldly/module-sdk/document-parts carries the core's verified, byte-bounded source reader. Explicit leases share network work and preserve active sources; aborting the final waiter cancels the existing bridge call. References use the generated schema contract in document-parts-types, without private package imports in the published declarations. The host fixes workspace and entity. The document.parts.read/document.parts.flush bridge and server doors share file-scoped pending-source journaling, exact reply validation and root-save confirmation. Verified clean sources reuse Meldly's existing asset cache and Settings storage budget across cold readers. Missing offline sources stay unavailable. Format-owned history pins, protocol fences and image activation remain unfinished. The standalone dev host deliberately reports these workspace methods unserved.

Developer docs on the web: https://my.meldly.app/developers/extensions.md (start here), this README as https://my.meldly.app/developers/module-sdk.md, the author guide and the runtime contract beside it, and every one of them in one file for a coding agent: https://my.meldly.app/developers/llms.txt. Questions and reports: [email protected].

Build a module for Meldly: a page that runs in a frame inside a Meldly workspace — an editor for a kind of file, or a view of the whole workspace with a button on Meldly's rail — and talks to Meldly through a typed bridge.

The quickest start is the starter project:

npm create meldly-module my-module -- --id <key>

Start with an idea in Extensions → Create an extension and copy its prompt to your coding agent. You can scaffold locally under a key of your choosing before registering anything. When ready to test host APIs, choose Register a build: Meldly shows the extension's address as <handle>/<key>, and the manifest's id is the bare KEY of it, never with the handle. Load the dev page beside it in Settings → Extensions → Developer mode. The extension's page in Meldly (Edit extension) also offers the starter download.

SDK 0.3 provides a public React UI kit, shared JSON and live-text clients, peer and agent presentation, host-quoted paid actions, the manifest's schema and meldly-module check. Start with the included step-by-step author guide; the bridge's envelopes, every host method with the permission it needs, and what an upload checks are in RUNTIME.md. No private workspace packages are needed.

Alignment feedback is shared with the host: ui.css supplies snap-guide plus snap-guide--x (vertical) or snap-guide--y (horizontal). Position a line on its alignment coordinate and set --snap-guide-inverse-scale to the inverse camera scale when drawing in content coordinates. For Canvas2D, paintSnapGuides from /ui takes the context, { x?, y? } in interface pixels, { width, height } and an existing computed style. Both use the public --canvas-guide color, solid two-pixel weight and dark keyline; selection retains the accent.

Versions. This README describes 0.3, which npm create meldly-module (0.4) installs; 0.2.5 lacks what is marked since 0.3. New since 0.2.5:

  • the subpaths /manifest, /manifest-check, /manifest.schema.json, /camera-motion, /panels and /drop-zones.css, and the meldly-module command;
  • in the base entry: words with plural forms (formatWord, wordsIn, untranslated), drop zones (registerDropZone, installDropZones, watchDragSession), createPresentationQueue, text feedback timing (textFeedbackRanges, textFeedbackSchedule, textFeedbackTiming), sendSelectionToAssistant, and the types of streams, calls, card ports and model calls;
  • in the UI kit: Checkbox, Toggle, plural-aware useWords, selectContent, describeContentSelection, createBlockMotion, createTargetHighlight, createTextFeedback, textPeerCaret, clearTextPeers, destroyTextPeerCaret, registerPasteTarget; and TextEditor now loads its editor when it first draws;
  • host methods: streams.*, calls.*, generation.call, generation.callQuotes, generation.job, generation.requestBudget, generation.budget, records.list, records.sources, extensions.placePanel, extensions.pointState, extensions.request, input.dragSession. A host older than a method answers it unknown_method (RUNTIME.md → Versions and compatibility).
npm create meldly-module my-tool -- --id my-tool --template ops
# --template yjs for collaborative text; --template workspace for a workspace view
import { bindUIHost, moduleHandlers, ModuleProvider, ModuleSurface,
  Field, Button, useDocument, PaidFileAction } from '@meldly/module-sdk/ui'
import '@meldly/module-sdk/ui.css'

The UI kit bundles Meldly's existing controls and peer renderer. The base entry remains dependency-free; Yjs and React are optional peers. JSON state, Yjs state, permissions, event spans, offline replay, MCP operations and charges keep their existing owners. Remote CPU/GPU services are not bundled with this release.

Dev host: run it before registering (@meldly/module-sdk/dev-host)

Meldly's side of the bridge, in memory, for a module nobody has registered yet. The starters' npm run dev opens it at http://localhost:5199/__meldly/ (__meldly/index.html in the project); the page itself stays at / for Meldly's Developer mode.

import { startDevHost } from '@meldly/module-sdk/dev-host'
import manifest from '../public/manifest.json'
import * as brain from '../src/brain.js'
startDevHost({ manifest, brain }) // a yjs door also passes yjs: Y, the page's one copy of Yjs

It lets the page in as the app does: the same hello, the same refusal of a frame that says it is another module. In development the starter's page accepts its own origin as a host (import.meta.env.DEV ? [...MELDLY_HOSTS, location.origin] : MELDLY_HOSTS), and the dev server's frame-ancestors names 'self'. A same-origin parent can script the frame anyway, so this grants nothing; a build answers Meldly's origins only.

| | In the dev host | | --- | --- | | Grants | manifest.permissions.data, checked with HOST_METHOD_GRANTS, the table Meldly's host checks. Untick one in the Grants tab: the frame reloads and meets Meldly's refusal | | ops door | document.read, replace, apply and history through your brain. Declared operations, the manifest's input schemas (the platform's own checker, built into the dev host), viewer refusal, per-author Undo and Redo. Refusals carry Meldly's codes: brain_refused, nothing_to_undo, unknown_operation, conflict | | yjs door | document.sync and document.update on one Y.Doc. A hidden session frame keeps the history, and history.undo/redo are routed to it. An agent's edit arrives as the room's, never as the person's step | | Session | Pane or card, theme, locale, role, UI scale and reduced motion, told as session changes. Switching pane and card reloads the frame, as a new frame in Meldly would | | Agent | The manifest's agent.operations run as one batch and one span the agent can undo; the Workflow tab shows what module_workflow will answer (dev.workflow(section?)), with its sizes | | Kept | The document, both authors' histories, storage.set values and saved colors, in localStorage | | Not here | Workspace data, files, pickers, library, streams and calls answer unknown_method, flagged "test in Meldly". The assistant answers unavailable. Generation and file actions are refused: nothing is quoted, charged or called |

DEV_HOST_COVERAGE classifies every HostApi method as served, quiet, absent, unserved or paid; a method added to the API does not compile until it is classified. The dev host's own words are English only, like the back office.

createTestHost is the same host without a browser, for vitest or node:test:

const test = await createTestHost({ manifest, brain })
const remote = test.joined.host.remote
await remote['document.apply']({ operations: [{ name: 'add', input: { id: 'a', text: 'A' } }] })
await remote['document.history']('undo')
await test.dev.operations([{ name: 'add', input: { id: 'b', text: 'B' } }], { actor: 'agent' })
test.close() // an open port keeps the process alive

test.joined is what joinHost gives a page: hand it to opsDocument or liveText. test.join({ host: 'session', handlers }) adds a session frame for a yjs history, and test.dev.log holds every call.

It does not check what only a real frame and server have: presses gating a host method, the cross-origin sandbox and device permissions, offline queueing, other people in the room, and object claims. Test those in Meldly.

Text collaboration and edit feedback

createTargetHighlight from the public UI marks a revealed block outside the source DOM. Create it for the mounted surface, call show(target) after scrolling and destroy with that surface. The default reading pause is 1.6 seconds with a short fade; input cancels, reduced motion stays static and hidden/removed targets settle. It preserves focus, caret and history. ui.reveal may carry the requesting agent's actorId; report true only for a real target in the owned document.

createPresentationQueue is the dependency-free scheduler for ephemeral agent demonstrations. Give each mounted projection its own queue. enqueue(plan) serializes known steps in a named lane; replace(plan) supersedes old live feedback in that lane. Other lanes advance independently on the same wake-up clock. A plan supplies a millisecond duration, timed {at, run} actions, an optional monotonic revision, a live valid guard and settled(result). Actions return cleanup and receive an AbortSignal. Tickets expose cancel() and an always-resolving finished promise. Destroy with the projection. Queued work is bounded to 16 plans, 64 actions and a 6.4-second admission age. Old revisions, invalid targets, overload and overdue background work settle without replaying a burst. Cleanup and setup failures cannot strand later work. Motion remains the animation engine; this queue never schedules content writes, navigation authority, saves or Undo. Native text and guided UI share this owner.

createBlockMotion from the public UI shares committed layout transitions with native Markdown. Supply at most 64 visible {key, element, x, y} targets in root CSS coordinates before a change, then play its new targets after native reconciliation. Keys must identify unchanged content; ambiguous duplicates skip motion. Destroy it with the editor. It adds no authored styles or writes.

describeContentSelection and selectContent expose the common selected-source context for an editor whose source is an entity body or declared text field. Supply the source and selected/context words locally; only a versioned address is published through ordinary presence. Assistant and MCP reads authorize and resolve it from the actual source, including stale/partial outcomes. This is read context, never write authority or a second history. Opaque binary/project selections remain their module's source-reader contract.

liveText supplies directional relative selections and person/agent identity to TextEditor, which shares named carets, selections and fast remote edit feedback with the workspace source editor. Markdown panes and canvas text faces use the same timing/controller through their native adapter.

Custom rich-text adapters import textPeerCaret, clearTextPeers and createTextFeedback from @meldly/module-sdk/ui. Use the caret as a native editor widget; pass committed decoration elements to feedback.play(elements, { agent, blocks }). Destroy the controller with that projection and strip transient marks from export clones with clearTextPeers. Human caret endpoints use the same Avatar as participant stacks, including initials and failed-photo fallback. Call destroyTextPeerCaret(dom) from a custom native widget's destroy hook. Built-in Markdown and TextEditor adapters own that lifecycle automatically; the widget adds no authored markup or history. TextPeerIdentity, TextPeerSelection, textFeedbackRanges and textFeedbackTiming are dependency-free base exports. Work is bounded to 160 grapheme groups and eight visible blocks, with a 6.4-second maximum lifecycle. Agent text reveals begin after pointer travel; long writes compress the same bounded schedule. Content is never delayed or rewritten to simulate typing. The controller settles on human input, backgrounding, live OS/host reduced motion, superseding work and teardown. Resolve identity from the owning room; ambiguous authors omit feedback. Call feedback.captureRemoval(range) before native reconciliation, then play(elements, { agent, blocks, peer }) after it. The bounded departure clone contains inert text only and never enters the document. point(element, peer) also shows connected agent carets using the same guided-UI mark; a null target dismisses that peer, and a missing peer dismisses all. Dispose with the editor. On an explicit menu/button press, sendSelectionToAssistant(joined, selection, preview) attaches a visible source reference to the host's existing composer. The host requires a fresh gesture in the source frame, flushes normal pending writes, checks workspace/frame lifetime and retains normal server access and revision validation. It never sends a message. Native Markdown and canvas text faces use the same staged-reference mechanism instead of implicit selection. binding.presence.remote() distinguishes a remote insertion from local undo or typing echoes; native adapters retain that provenance when projecting changes.

Shared image, video and audio rendering

React extensions can use the same renderer as the built-in Media Viewer, cards and document embeds. The optional entry bundles its implementation and styles; the base SDK does not load React or a media player.

import { joinHost, MELDLY_HOSTS } from '@meldly/module-sdk'
import { bindMediaHost, MediaView } from '@meldly/module-sdk/media'
import '@meldly/module-sdk/media.css'
import { createRoot } from 'react-dom/client'

const joined = await joinHost({ module: manifest.id, version: manifest.version, hostOrigins: MELDLY_HOSTS })
await bindMediaHost(joined) // once per frame, before rendering
const [file] = await joined.host.remote['entities.get']([assetId])
if (file) createRoot(document.getElementById('root')!).render(
  <div style={{ height: 400 }}><MediaView file={file} controls /></div>,
)

Use React and React DOM 19. Declare entities.read for that entity lookup and files.read for scoped file capabilities. An entity already returned by a host picker can be passed directly. There is no host session token in the frame. drawing asks for bounded thumbnail copies; still keeps decorative previews from creating players. Give the component a box with a height. Saved crop, shape, effects, looping and trim come from normalized read-only presentation; these props do not grant editing rights. Missing or forbidden bytes show the shared failure state. Native file authoring remains in the host's file tools.

The shared players coordinate foreground audio/video through the host. Starting a preview in an extension pauses the previous core or extension preview, and stale pause/release messages cannot stop its replacement. Composition mixing uses its separate timeline lane. A custom player can opt into the same SDK foreground coordination contract without adopting React.

Performance on boards

Every extension shares the workspace's CPU/GPU, memory and network budget. Implement the host's suspend/resume lifecycle, report owned resource estimates, keep previews bounded, and release hidden rendering work. Test many mixed cards and multiple boards as well as a single editor. The shared performance contract is required reading for card and iframe authors.

The page

import { applyTheme, joinHost, MELDLY_HOSTS } from '@meldly/module-sdk'
import manifest from './manifest.json'

const joined = await joinHost({ module: manifest.id, version: manifest.version, hostOrigins: MELDLY_HOSTS })
applyTheme(joined.session.theme) // Meldly's look as CSS custom properties

const graph = await joined.host.remote['graph.read']({}) // needs "graph.read" in the manifest
joined.host.on('graph.changed', () => { /* read again */ })
await joined.host.remote['shell.open']({ id: graph.nodes[0].id })
  • joined.session — who is looking, the file the frame was opened on (if any), the workspace view it was opened as, the language, the theme, whether the person may edit, and how an amount of AI credits is drawn (credits beside a mark, or US dollars). joined.onSession hears it change. session.entity is { id, type, kind?, title, spaceId, parentId }, and for an uploaded file also mime and blobHash — what a viewer needs to read the bytes: files.capabilities([blobHash]) with files.read. Both are absent from a host older than this, so read them as optional.
  • joined.host.remote[…] — what Meldly does for the page: read and save its document, read files and entities it was granted, open a file, show Meldly's pickers, upload, say a notice, keep a setting on the device, read the link graph. Every call is checked against the permissions the workspace granted.
  • joined.host.on(…) — what Meldly tells the page: the session changed, its document changed, the graph moved, somebody moved on its surface.

HostApi, FrameApi and HostEvents (exported as types) list every call and event with its documentation.

Hosts can reveal an object inside the owning editor through an addressed UI arrival. Sessions carry personal color presets, with writes delegated back to the host rather than stored in a document.

Sessions also carry the host's exact presence surface and optional canvas slot. Portable players use that scope for Follow instead of guessing a room from a document's type. Older hosts may omit it; host-side presence pinning remains the authority on where a frame can announce.

Presence clients can request a current snapshot after attaching listeners. Sessions distinguish document coordinates from a containing card playback scope; the host enforces both addresses. These are ephemeral viewing state, without document edits or undo entries.

Visible overlays such as fullscreen previews register their Back dismissal through ui.backLayer and close when its addressed host event arrives. The host orders these layers with its drawers and dialogs; a frame keeps no competing browser-history stack. IDs are scoped to one frame, registration and release are idempotent, and disconnecting the frame releases its registrations.

Module files can have child files. records.children lists a declared kind under a parent in bounded pages; the host uses the cached entity API and returns nextOffset until exhausted. Read documents in batches with records.read. entities.pick accepts an optional documentMatch of exact top-level string fields, so a module can offer only cameras, plans or another document subtype. These are generic module capabilities, not a host-owned film schema.

The shared file picker can include additional entityKinds (type plus optional renderer kind), such as editable HTML/video projects. These are ordinary visible entity rows selected by the person; the response remains bounded metadata.

Workspace homes use records.list({ type, kind?, query?, offset? }) to page the module's exact owned document shapes. records.changed nudges a reread after workspace changes; revalidate on focus to recover missed nudges. Lists use the host cache and require entities.read. Read visible documents through records.read, and their source facts through records.sources(ids) (at most 64 IDs, also requiring files.read), rather than retaining every editor or decoder in a gallery. entities.pick({ types: ['folder'], render: 'tree' }) uses the shared Files folder tree. entities.location({ parentId }) validates the chosen visible folder and returns its actual space and parent; it writes nothing. Document creation still goes through the ordinary attributed, reversible entity door, including permissions and events.

Two things the page gets without asking:

  • Discoverable drop destinations. Every real drop destination must declare accepted MIME types using registerDropZone(element, types) (base SDK or UI), or data-drop-types="Files …" when the shared UI installs its controller. Nested destinations use one container hint; a frame's body is its default data-drop-scope. Scope a native pane/list the same way. Children highlight only when hovered and accepted. Explicit Back/navigation targets may use { hint: 'always' } or data-drop-hint="always"; use hint: 'hover' for secondary targets such as tab strips. Import @meldly/module-sdk/drop-zones.css for non-UI pages; UI CSS includes it. Call acceptDrags(host) once per frame and dispose both registrations on teardown. Discovery markers are metadata; they draw no dashed outlines. Only an accepted innermost destination under the pointer receives the shared background highlight. Insertion positions use solid lines. Do not reduce the content opacity. Use disabled, aria-disabled="true" or data-drop-disabled for refused destinations. Keep existing drag/drop handlers: registration grants no access, writes no data and never claims an event. Host and sibling frames exchange only bounded MIME metadata, including for desktop drags; bytes arrive only with the existing accepted drop. Completion, cancellation and disposal clear it.

  • Workspace and desktop drags. Desktop files keep their native browser dragenter / dragover / drop events. For drags originating in Meldly, call acceptDrags(joined.host) once (the UI client's host binding already does). The host relays across origins as ordinary dragenter / dragover / drop events at the local point. Accept with preventDefault() and dataTransfer.dropEffect; unaccepted drops remain the host's. Protected hover exposes types, including Files, with no bytes. An accepted drop receives real File objects in dataTransfer.files, structured-cloned without converting bytes to text. Upload through files.import; grants, quotas, offline upload handling and history remain the host's. Dispose the returned listener on teardown.

  • One colour with the tab. Meldly's tab is the top edge of your page, so joinHost measures what colour your top edge is painted and Meldly paints the tab the same. Nothing to do; reportSurface: false turns it off.

The keys you do not use. forwardKeys(joined.host) hands Meldly the shortcuts your page left alone — a handler that called preventDefault kept its key — so Ctrl+K, Ctrl+Z and Escape mean in your frame what they mean everywhere else. Ctrl+Z in a field somebody types in stays the field's, and any other key typed in one goes with typing, so Meldly leaves it to the field wherever it leaves its own fields' keys alone: Ctrl+B in your text box is not Meldly's sidebar, while Ctrl+K still opens search. If your page keeps an undo history Meldly cannot reach, say so: forwardKeys(joined.host, { history: step => … }), and Ctrl+Z, Ctrl+Shift+Z and Ctrl+Y pressed anywhere in the frame outside a text field are yours — the page's body included, which is where focus lands after a button removes itself. Without it those keys go to Meldly, whose undo takes back the person's latest other work. routeKey is the decision, if you forward keys yourself.

Explicit source refresh

createRefresh is the shared explicit-read controller used by Meldly lists and available to extension views. Keep one instance per mounted data context:

import { createRefresh } from '@meldly/module-sdk/refresh'

const refresh = createRefresh(() => sharedDocument.refresh())
await refresh()

Concurrent calls share the same pending promise and result. Once it settles, the next call reads again; failures reject and can be presented by the caller. Construction starts no request, timer or subscription. The existing source owns permissions, offline policy, stale-response protection and optimistic work. Create a new controller when the workspace/document context changes. Refresh does not replace the editor, reset its view state or create an Undo step.

Keyboard commands

A key pressed in your frame never reaches Meldly, so Meldly's command palette (Ctrl/⌘+K) and its keyboard reference know your editor's commands only if you declare them. Then, while your frame has focus, the palette lists them first, under your module's name, and runs the one a person chooses by asking your frame — you run your own commands, exactly as their keys would; Meldly never presses one of your keys and never writes for you.

import { frameCommands, joinHost } from '@meldly/module-sdk'

const commands = frameCommands()
const joined = await joinHost({ ...options, handlers: { ...commands.handlers } })
const offer = () =>
  commands.offer(joined.host, [
    { id: 'brush', label: words.brush, group: words.tools, keys: ['B'] },
    { id: 'flip', label: words.flip, group: words.edit, keys: ['Shift+H'] },
  ], (id) => run(id)) // false: it did not act
offer() // and again whenever a key or the language changes; null withdraws them

Keys are written as Meldly writes them — Mod+Shift+Z (Mod is ⌘ on Apple keyboards, Ctrl elsewhere), B, Shift+[, ←, a sequence G I — and are the keys your page answers NOW, the person's own if they changed them. The words are yours, in the session's language. Settings → Keyboard lists them while your frame is open. Supply an optional fourth commands.offer argument { info, change } to delegate profile and rebinding requests to your own keymap. Without it the list remains read-only. At most 200 commands, four keys each. Leave out a command that needs a press inside your page (reading the clipboard, the system eyedropper): a choice in the palette is a gesture in Meldly's page, not in yours. An older host simply lists nothing.

joinHost automatically bridges the host's navigation bindings, including G sequences and remaps. Navigation prefixes take priority over editor tool letters; typed fields, IME and local dialogs keep their keys. Open menus retain typeahead even when their trigger keeps focus outside the portaled panel. The host supplies compiled physical-key patterns, so the frame has no second shortcut parser. This requires an extension build using SDK 0.2.2 or later; immutable installed releases retain their original SDK until updated.

Assistant panel

A module can read or set the host's assistant sidebar through the same controls as the person. It never submits a prompt or exposes the conversation. An optional draft appends editable text to the composer and opens the panel; the person sends it. On phones the panel is a bottom sheet.

const state = await joined.host.remote['shell.assistant']()
await joined.host.remote['shell.assistant']({ open: true, width: 480 })
await joined.host.remote['shell.assistant']({ open: false })

The answer reports availability, open state and the actual clamped width. Layout requests are idempotent; each draft request appends once. An unavailable assistant refuses opening and draft insertion. Existing writing is preserved. Closing the sidebar restores the conversation on desktop Home.

Reading needs nothing. Every other request is the person's to make, so call it from a press in your frame: one without is refused. A draft or a new chat is accepted at most once every two seconds. The composer names your extension while its text is in the draft, and a turn sent with it is stored as your extension's words too — untrusted data for any agent that reads it later — unless Meldly vouches for the extension.

Use shell.assistant({newChat:true,draft:context}) to open a fresh standard conversation with an editable context draft. It refuses while a reply is running; it does not send, pay, expose history or grant access. Existing composer writing is preserved. Prefer this action for a compact card; embedded onboarding remains available to modules that deliberately need it.

Canvas preview handoff

Call joinHost({ ...options, deferReady: true, handlers: { snapshot } }) for an editor that loads its code or document asynchronously. The host retains the previous card image until ui.ready(true), independently of ui.surface color and the pane chrome timer. React editors under ModuleRoot call useModuleReady(hydrated) from @meldly/ui/module; other frameworks call reportReady(joined.host) after their usable face has painted. Report a truthful empty or terminal failure state too. Ordinary revalidation retains the live UI. In a pane useModuleReady reports once the face is mounted, whatever it was given: a pane has no picture to keep, so its media load inside the editor, and a pane shows a connected editor after a few seconds even if it never reports. Older hosts receive the deferred surface report as a compatible fallback.

A snapshot({width,height,live?}) handler returns PNG bytes of the actual face, including its UI. The shared @meldly/module-client/frame-picture handler uses the same bounded raster path as the app, refuses overlapping captures and passes the bridge call's cancellation signal to raster work. Preserve the handler's this context; do not wrap it in an arrow that discards that signal. This does not require preloading additional editors or retaining extra frames.

The manifest

manifest.json, beside the page, says what the module is and may do: its id and version, the views it draws (host: "pane" for its files, "workspace" for a view of the whole workspace), what it adds to Meldly (a rail button, entries in a file's menu, the mark its files wear in the tree, the files it shows and the other modules' documents it opens — below), and the permissions it asks for (files.read, entities.read, graph.read, presence.read, …; device permissions such as camera). Meldly reads it before the page loads; nothing the page does reaches past it. Its id is the module's KEY — the <key> of the address <handle>/<key> Meldly shows, without the handle.

Its shape is published with the SDK (since 0.3), written from the platform's own schema so it cannot say what an upload does not:

// public/manifest.json — your editor completes and checks it
{ "$schema": "../node_modules/@meldly/module-sdk/dist/manifest.schema.json", "manifest": 1, "id": "word-count", … }
import type { ModuleManifest, ModulePermissionName } from '@meldly/module-sdk/manifest'
import { checkManifest } from '@meldly/module-sdk/manifest-check' // in Node or a test
npm run check   # meldly-module check: the starter runs it first in npm run build

meldly-module check [public/manifest.json] [--brain src/brain.js] [--strict] runs the upload's own schema and acceptance rules (kinds the key does not name, version ranges, input-schema keywords the platform refuses such as pattern, an agent guide too long to read whole) and what only a linter sees: keys Meldly would silently drop, keys Meldly keeps for itself, enum typos with what was meant, brain functions brain.js does not export, agent operations it never names, and — as warnings — labels with no ru or ja and untranslated(…) left in src/. Errors exit 1; --strict makes warnings fail too.

Kept on the device

A release is kept on a device before anybody opens it there, so the module opens on a train. Two calls do it, and the starter makes both:

// vite.config.ts — writes sw.js (every file of the build) and warm/ beside the release
import { meldlyOffline } from '@meldly/module-sdk/offline'
export default defineConfig({ plugins: [meldlyOffline({ hosts: MELDLY_HOSTS })] })

// the page — puts the release's worker in
import { keepOffline } from '@meldly/module-sdk'
keepOffline()

A file over 2 MB is kept the first time it is fetched rather than ahead. So is one named in leaveOut, by the name the build gives it before the hash, whatever its size: code only some devices run, such as an encoder that stands in where the browser has none, or code that cannot run offline at all, which warmed everywhere would be a download for every phone that keeps the release.

A file the page shows — a <video src>, a poster, its bytes — is reached through a capability the host mints for a window of an hour or two. Spell its address through fileAddresses and it is minted ahead and again before the window ends; where the release's worker stands behind the page, the address is a name that never expires (<release>__file/<copy>/<hash>), so a film playing across a window keeps its address and keeps playing:

import { fileAddresses } from '@meldly/module-sdk'
const files = fileAddresses({ capabilities: (hashes, copy) => joined.host.remote['files.capabilities'](hashes, copy) })
await files.warm([{ hash: film.blobHash, mime: film.mime }])
video.src = files.address('original', film.blobHash)

Meldly loads your release's warm/ page unseen, once per release on a device (never on a connection the person asked to spare); it registers the worker and says so to the app alone. Files over 2 MB are kept on first use instead. The worker keeps the three releases most recently started on your site. A release built without warm/ simply waits to be opened before it is kept. The same worker and warm page serve the modules Meldly ships.

No network

A module's page has no road out of its frame but the bridge: its site serves it with a policy that allows no outside address but one read-only folder of public models (MediaPipe's: face, pose, segmentation), the same for every publisher and for Meldly's own. Whatever else it needs from beyond, it asks Meldly for, where the person's grants apply. To run authored HTML it may frame the HTML relay (html.meldlyusercontent.com), whose sandbox has no network at all. (One road no policy closes is WebRTC; what a module may read is therefore what its data grants say: RUNTIME.md → Network and origins.)

Inside the package

What it is for. A module runs in a frame on another site and reaches Meldly only through a MessagePort (RUNTIME.md → The bridge). This package is that conversation, the same code on both sides: the host app imports it to let a frame in and serve it, a module imports it to be let in and to ask. It is the SDK's floor — everything a module author calls is built on it.

What is in it

  • The protocol — the messages that cross the port (call, result, event, cancel, and the hello/welcome that opens it), a version a frame is refused for not speaking, and failures as data with a fixed list of codes, because an exception does not survive a structured clone.
  • The endpoint — one side of the bridge: calls out as promises with a timeout and an optional abort signal each — giving up, either way, tells the other side to stop — calls in served by handlers that read their call's signal and checked against what the module was granted, events both ways, bytes handed over rather than copied when the caller says so, and every waiting call answered closed, every served one stopped, when the bridge goes.
  • The door — the frame's hello and the host's welcome or refusal: a frame that is not the module the host loaded, or speaks another protocol, is turned away with the reason before it can say anything else. Before it, the handoff: the port is posted to the frame's window for its origin only, and the frame takes it only from its parent and an origin it expects.
  • The API, version 1 — what the host offers a frame (files through capability URLs and the app's picker, entities by id as summaries, bytes stored, device files stored as files of the workspace, entities of the module's own kinds created, the shared document's sync and updates, a JSON document read and replaced against what was read or changed by the module's own requests through its door (the ops door, conflict when it moved, its state told back), another entity's live document watched read-only, the app's entity picker, the tab's history — a gesture's steps handed to it, Undo and Redo, what it holds — forwarded keys, wheels and captured pointers, presence on the frame's own surface (gestures in flight included) and, granted presence.read, who is in the workspace and where, notices, opening an entity, resource reports, the module's own settings kept on the device, where the assistant's guides may point inside it, the host's file actions run on the person's gesture in that frame — with the one choice an action asks before it runs, by file type (a list, or with no choices a line typed in under its placeholder) — the app's screen recorder and model workshop, the link graph as the person may see it, the frame's keyboard commands listed in the app's palette and reference) and what a frame offers the host (its tab's history, suspend and resume, a picture of itself, the input a file action asks for, one of its commands run), with the session a frame is told on arrival — who is looking, where, in what role, language, theme and scale, which modules are on, and the room to leave for the host's chrome (and the frame saying where it is).
  • A release kept on the device (offline) — the release's service worker and its warm/ page, written beside a build by a Vite plugin, and the call that registers the worker from a page: one for every module, Meldly's built on it too. Import-free, because a build's config loads it through Node itself.
  • The manifest's shape (manifest, manifest-check, manifest.schema.json, the meldly-module command; since 0.3) — the TypeScript type and the JSON Schema, both written from the platform's schema at build time, and the check an upload makes, bundled with that schema so it runs offline.
  • What looks like a secret (secrets, since 0.3) — a file's name or content checked the way the upload door checks it (keys, tokens, .env, .git-credentials…), the platform's own check built in; the starter's npm run pack uses it on both ZIPs.
  • A module's own words (words, since 0.3) — catalogs in en · ru · ja, forms chosen by each language's plural rules, untranslated for English standing in until a translation is written.
  • The runtime contract (RUNTIME.md, since 0.3) — the envelopes, every host method with the grant it needs (written from the code), the document doors and what an upload checks: what an author used to need the platform's repository for.
  • The dev host (dev-host) — the host's side of the bridge in memory, for development and tests: a page that frames the module (startDevHost) and the same host over a MessageChannel (createTestHost). It answers the document doors through the module's own brain and refuses, in words, what only Meldly has. Plain DOM, no dependency.
  • Files by address (file-addresses) — the page's half of reading files: capabilities minted ahead and renewed, and the names that never expire the release's worker asks the page about.
  • The frame's kit — what a module calls on its side: joining the host and keeping its session current, its keyboard commands offered to the app's palette and run when chosen there, Meldly's theme applied to its root if it wants the look, its Yjs document kept a replica of the host's (with a sibling frame's edits told apart from the room's, and each by the kind of change it said it was, for the session frame's history), and the shortcuts, wheels and captured drags it did not use handed back. The replica is its own entry, so a module that keeps no Yjs document loads no Yjs; a module that does brings the one copy of it.

Bridge failures keep one class identity across protocol and lightweight error imports, so formatting a failure does not load transport validation.

What is deliberately NOT here

  • The host's implementation of the API — permissions, the Yjs document, the capability mint, the input bridges: the web app's module host (the web app's module host), which has the stores and the session this package must never see. The dev host is a stand-in for development; it shares the host's grant table (HOST_METHOD_GRANTS, which the app imports from here) and carries the platform's operation input check, built in from the platform's schema when the package is built. It is never what Meldly runs.
  • UI in the base entry. The optional ./ui entry bundles the public shared controls and theme; an author does not install the private workspace packages.
  • The listing — the name, description and pictures a person reads in the catalog. It is edited on the extension's page in Meldly (Edit extension → Listing) and reviewed there; no file of the release carries it.

License

MIT

Meldly-authored code is MIT; optional bundled dependencies keep their own licenses. Distribute dist/THIRD_PARTY_NOTICES.md and read THIRD_PARTY_SOURCES.md before redistributing a bundle.

Assistant onboarding inside a module

mountAssistant(joined, element, options, onError) reserves an area for the host's existing conversation. Call after the person chooses this mode and dispose on unmount. No React dependency is needed. The host clips the area to the iframe; resizing, scrolling and replacement mounts keep the same composer alive. A host without this capability rejects the request; show a fallback using onError.

import { mountAssistant } from '@meldly/module-sdk'
const dispose = mountAssistant(joined, container, {
  compact: true,
  onboarding: {
    title: localizedTitle,
    description: localizedIntroduction,
    context: ENGLISH_TASK_BRIEF,
    starters: [{ label: localizedChoice, prompt: ENGLISH_DRAFT }],
  },
}, showUnavailable)
// Later: dispose()

Reserve enough height for the conversation (around 400–600 px). compact makes the standard composer a single row, with multiline expansion, dictation, model settings and attachments still available. This is a placement of the person's current conversation, not a new agent or an isolated module-owned thread. The person can start a new chat from its normal header.

Titles, introductions and starter labels are read by the person, so the module localizes them. Task context and starter prompts are read by the model, so they are English constants in the module's code, never entries of its words catalogue: an instruction that changes with the interface language is three prompts, two of them untested. Do not add "reply in …" to them; the host's assistant is told the person's language on every turn. A starter is picked by its label and drafts its prompt. Context is visible and explicitly untrusted. It is attached only when the person sends a turn; opening the area never sends or charges. A turn sent there, and a draft a starter wrote, are stored as your module's words as well as the person's, so an address your context or starters name never counts as one the person wrote down — unless Meldly's staff vouch for your module. Starter choices and assistant choices fenced JSON string arrays prepare editable replies. The module cannot read the transcript, submit messages, obtain credentials or expand permissions. Document creation uses the existing workspace tools, events, undo and realtime path. Offline conversations retain the host's ordinary limitations.

Preset media

Module authors can bundle shared preview assets with their versioned release, or resolve workspace asset references through the existing file capability door. React modules use the shared Picker for still, GIF and video choices. Atlases can share one source image across several thumbnails. Storage, access and publication remain separate from rendering. A preset never carries a workspace's private file into a release: it names an asset, and the person's access decides whether it resolves.

Your kinds

Your key names your kinds: <key>:<kind> (acme-paint:pic), and the kind that is your key itself (acme-paint) — never <key>:<key>, which is that kind. A kind opens in your view of the same name (acme-paint:pic in view pic, acme-paint in view acme-paint). A name the platform stores itself (canvas, text, scene) is not yours whatever your key, and a manifest claiming it is refused where it is accepted.

Live documents (the yjs door)

Declare document: { door: "yjs", maxBytes } and the host keeps each of your documents as ONE live Y.Doc per entity in the tab — on the device, in the document room with everybody else who has it open, and written down through your own door — and every frame of yours is a replica of it:

import { replicate } from '@meldly/module-sdk/yjs'
const replication = replicate(host, doc)
await replication.synced
// True for exactly one frame, once, when the document is new everywhere:
// lay down your empty document in one transaction. Every other frame waits.
if (await replication.seed) doc.transact(() => createEmpty(doc))

The host knows nothing of your format: it never writes a first state of its own, and a frame not told seed never writes one either — two would be two documents in one. Its history is yours, in your session frame (history.*), never the app's Ctrl+Z. A state over maxBytes is never saved (the room holds 1 MiB a message and 8 MiB a document besides).

So a creates entry of a yjs module names no document template: the person's "Create" makes the file empty, of your kind, and the first of your frames to open it is told seed. Removed or switched off, your module leaves its documents downloadable as JSON — the whole Yjs state as one update (state.base64, door: "yjs"), which Y.applyUpdate reads back.

Compatible Yjs editors may opt into document.historyFormat, a stable identifier for their gesture/history wire format. The host shares one hidden history frame per workspace, format and document; incompatible or undeclared formats retain separate namespaces. This is compatibility metadata, never an access grant. Exact declared native-format openers can borrow registered Yjs storage metadata while its owner runs. Read grants mark that projection read-only; browser saves and agent batches independently refuse writing through it without a write grant.

Streams (the stream door)

A channel, a feed, a log: many small items appended in order and read a page at a time. Mark the kind door: "stream" (kinds: [{ type: "note", kind: "acme-chat:channel", door: "stream" }]) — beside any document door, or none — and its documents' items live in the platform's stream store, never in a Y.Doc or a JSON prop. A creates entry for it names no template: the person's "Create" makes it empty.

const page = await joined.host.remote['streams.page']({ stream: id }) // the newest 50; also before / after / around
await joined.host.remote['streams.append'](id, [{ clientId: crypto.randomUUID(), kind: 'message', body: { text } }])
joined.host.on('streams.items', (data) => { /* append; a gap in seq: page `after` the last seq held */ })

seq is dense from 1 and is the order. Paging a stream watches it: new items arrive as streams.items (your own included — match them by clientId), edits, deletes and reactions as streams.changed. An append with the same clientIds lands once; offline it waits and answers queued. A body is JSON of at most 8 KB; an item with trusted: false is an agent's, shown as data. Access is the stream's own and your module reaches only its own stream kinds — the server checks every call. Typing and "is reading" are presence on the frame's surface, never items. Since 0.3; RUNTIME.md → Document doors.

Comments

Your document can carry comment threads and reactions: pins on your own surface, a panel from the shell. Nothing to declare beyond entities.read, and you may ignore all of it: the person can still comment on the whole file from its ⋯ menu.

You say what a place is in your own coordinates. The panel, the composer, images, voice, mentions, read state and inbox lines are the shell's, and improve without you publishing again. A frame never holds a microphone.

import { useFrameComments } from '@meldly/module-client/comments/use-frame-comments'
import { CommentPins } from '@meldly/module-client/comments/CommentPins'

const comments = useFrameComments()
comments.start({ kind: 'point', x, y, space: `frame:${frame}` }, pinRect) // the box for a new thread

<CommentPins
  comments={comments}
  project={(anchor) => (anchor.kind === 'point' ? toScreen(anchor) : null)}
  reproject={onCameraChange}
  capture={(clientX, clientY) => ({ kind: 'point', ...toImage(clientX, clientY) })}
  shows={(thread) => thread.anchor.space === `frame:${frame}`}
/>

Anchors: point (with a space where you have several), time (seconds, optionally a clip and offset so it follows a moved clip, optionally until), object (an id you own), whole. Draw your own markers from comments.threads where pins do not fit, as a timeline's ruler does, and call comments.open. comments.reveal names a thread reached by a link: bring it on screen, then clearReveal.

Agents reach the same threads through read_comments and post_comments, so an anchor you define is one they should be told about in your guide.

Agent operations

Declare agent: { operations: [{ name, description, input, writes }] } and an agent finds them as one tool for your module, <key>_operations, wherever your module runs: a batch over your documents, each with your operations by name, run in order under one span (one revert takes it all back). Your descriptions and input schemas reach the agent as YOUR words through module_workflow, marked as data, never as instructions.

How to work with your documents is a GUIDE, in sections. An agent reads a tool's answer in one go, and every client stops somewhere: the in-app assistant cuts an answer at 8 000 characters, other clients move a long one aside. So guidance goes in agent.guide, never in a long operation description or a schema $comment:

"agent": {
  "operations": [{ "name": "add", "description": "Add items.", "input": { "type": "object" }, "writes": true }],
  "guide": [
    { "id": "steps", "title": "Steps", "summary": "The order a task takes.", "text": "1. Read the document..." },
    { "id": "vocabulary", "title": "Vocabulary", "summary": "Every preset, by id.", "text": "..." }
  ]
}

module_workflow answers your contract, your operations and the guide's index (ids, titles, summaries) first, and one section's text when asked for it by id. An agent sees these only once a version is uploaded with its brain and installed; Developer mode swaps only the page (GUIDE.md → What an agent sees, and when). Before that, the dev host's Agent and Workflow tabs show both. The upload refuses a manifest whose operations and index pass 6 000 characters as JSON, or a section that does — as JSON text, so quotes and line breaks count. Up to 24 sections; a guide that grows is a section more. Developer mode shows the same refusal when it loads your page, before you pack anything.

Every input is checked against its schema before any operation of the batch runs. Meldly checks a SUBSET of JSON Schema — type, enum, const, properties, required, additionalProperties, min/maxProperties, items, min/maxItems, uniqueItems, min/maxLength, minimum, maximum, the exclusive bounds, multipleOf, allOf, anyOf, oneOf, not, and the annotations (description, title, default…). A manifest using any other keyword (pattern, format, $ref…) is refused at upload rather than half-checked: check those in your brain.

Your brain runs them. For an ops document, ship brain.js at the top of your build, beside manifest.json, and declare it — brain: { functions: ['apply'] }. It is one self-contained ES module (bundle anything it imports) exporting apply(document, operations, context), where operations is the list of { name, input } the agent sent for that document, in order:

export function apply(document, operations, context) {
  let next = document ?? { items: [] }
  const inverse = []
  const results = []
  for (const op of operations) {
    if (op.name === 'add') {
      next = { ...next, items: [...next.items, op.input.item] }
      inverse.unshift({ name: 'remove', input: { at: next.items.length - 1 } })
      results.push({ count: next.items.length })
    } else throw new Error(`no operation ${op.name}`) // refused: the agent reads your message
  }
  return { next, inverse, results } // results: optional, one per operation
}

next is the document the operations made (a JSON object, at most your maxBytes), inverse the operations that take them back, results what each operation answers the agent. Meldly reads the document, calls your brain in a sandbox of its own — no network, no bindings, a CPU cap per call, one isolate per workspace — and writes next against the document it read, under the batch's span, so one revert takes the agent's work back. Throwing refuses the operation; your message reaches the agent marked as untrusted data. The same file can run in your frame (import('./brain.js')).

context.actorKind comes from the authenticated caller, never from an operation's input. Enforce format-specific actor restrictions in the planner. Keep results compact: the complete results array is limited to 64 KiB, and inverse data to 4 MiB. Return conditional, object-level inverses that can be applied over later work; replacing the whole old document loses unrelated edits.

A yjs brain receives the merged state as a Uint8Array, then the operations. Its apply returns { update, inverse, replay?, results? }: a Yjs update and JSON operation records for undo and redo. The core bounds and validates the answer, merges competing saved state, reserves workspace bytes, records the event and delivers the committed update to the live document room. Declare the summary, still or view functions your format needs; image renditions are checked against their actual MIME signature and byte budget. Missing saved state refuses an edit instead of starting an empty document.

A declared view document uses the same published-operation entry point with the common placement/revision storage contract. The platform retains legacy physical bindings for existing formats; another publisher uses declared kinds without receiving those bindings by naming a module ID.

Staff may disable published brains globally or per module (brain_off). A missing brain answers operation_unavailable; unavailable isolate support answers brain_unavailable. No publisher code runs in the API process. These are visible refusals; a whole-document JSON replacement is a separate door with its own permissions and exact-baseline checks.

Operations, sources, history and outputs

An ops frame can invoke its declared operations through the same server planner as an agent. A connected call needs only the operations and an optional gesture span. To let a human edit wait offline, supply an explicit optimistic projection:

const before = await joined.host.remote['document.read']()
await joined.host.remote['document.apply']({
  operations: [{ name: 'add', input: { item } }],
  spanId: crypto.randomUUID(),
  optimistic: { document: locallyPlannedDocument, expected: before },
})

The projection updates the host's shared mirror and is stored only on the device beside the queued operation. It is not sent as authoritative state. On reconnect the server runs the published brain against current data under the original span and idempotency key. Rejected permission, missing-row or conflict replay reaches the host's rejected-work UI; an immediate refusal restores the previous projection. Without optimistic, operation writes require a live connection. Yjs keeps its own device log; do not add a JSON draft or operation queue beside it.

Ops editors share the host's immediate history journal. Submit each gesture to the bridge as soon as it is drawn, including its optimistic document; do not wait for earlier saves before calling history. The host draws Undo/Redo before serializing the server request, then reconciles the returned document. Ordinary document replacement uses the app's same journal. A delayed acknowledgement cannot remove a newer pending draft or a later history press.

DocumentOperationsRequest describes that presentation envelope. Set optimistic.keyedArrays: true only for a format whose object arrays have unique, stable string IDs. Conditional inverses then follow those IDs and preserve peer insertions and unrelated fields; other arrays are compared atomically. Missing baselines, conflicting values and oversized changes have no local guess and wait for the server. The exported conditional projection and bounded span journal provide the same primitive to custom hosts. Draft drawing is a separate presentation action, never an inverse and never a persistence plan.

An optional local optimistic.operationId is acknowledged in document.changed.includedOperations. A client with its own pending overlay must stop overlaying those drafts once the host snapshot includes them. Events also identify the file and history press/phase, so a frame can reject a foreign snapshot and retain newer drafts. These tokens never enter the server's request body. A refused write or a history answer for a different source span abandons dependent guesses; it must never consume an older accepted gesture instead.

An editor with an explicit Save button can use DraftHistory for its unsaved layer. Record each local change with its gesture span and draw the result of step('undo' | 'redo', current) immediately. On an accepted save, checkpoint(saved, current) transfers that accepted prefix to host history and retains only changes made while the request was pending. A failed save leaves the draft history intact. Do not apply this owner to Yjs/text or to already persisted JSON operations. The portable editor client can register a scoped draft owner so frame shortcuts and commands choose it before host history.

For JSON operations, declare document: { door: 'ops', history: 'author', maxBytes } for a document whose operations have one history per author. Then call joined.host.remote['document.history']('undo') or 'redo'. The core applies recorded inverses through the original release's brain over the current document. A stale inverse refuses without consuming the entry. Undo/redo is online-only. Claims are advisory leases rather than undoable work; touched targets returned by a brain are checked before an agent's commit, and a person's committed edit breaks conflicting leases. For placement-backed views, the core also computes object targets from actual inserted, removed and changed placement IDs. A published brain cannot bypass an object lease by omitting its touched metadata.

Read visible source content through the bounded shared source graph:

const sources = await joined.host.remote['document.read']({ kind: 'sources', ids })

This requires both entities.read and files.read. It returns a source array, including supported format projections, not the current document. It uses the host read cache; an uncached offline source is a visible refusal. The core checks every source and project child against workspace access and bounds the graph and its bytes. Use your published references and signature functions to describe format dependencies; a reference never grants access by itself.

An ops door may also support a module-owned document.read(query) request. A query requires both entities.read and files.read; viewers may use it. The no-argument read remains the current document. The owning door validates/bounds queries and checks source access through the same API as the app. Entity summaries may include the renderer kind; arbitrary props and file bodies are not metadata.

To attach an exported file, declare document.output: { property, mime } and published references and output functions. Upload the actual asset through the standard file pipeline, then call:

await joined.host.remote['document.output']({
  output: { entityId: exportedAssetId, sourceSignature, version: 1 },
  spanId: crypto.randomUUID(),
})

The core requires a confirmed, accessible workspace asset of an allowed MIME, asks the pinned release's output(document, sources, request) to validate the reference and rechecks source revisions and project membership in the commit. A new or moved-in HTML child invalidates the old source proof too. The resulting output attachment has an attributed, undoable event. Output publication requires a connection; arbitrary file IDs or stale render signatures do not pass it.

document.changed can carry a document snapshot or a transient activity envelope: { moduleClaims }, { moduleFeedback: { phase: 'begin' | 'landed' | 'cancel', edit } }, or { sourcesChanged: ids }. Claims and feedback use the document's presence room, m:<key>/document:<id> (the host's surface). Feedback begins before an authoritative reread