@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.
Maintainers
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,/panelsand/drop-zones.css, and themeldly-modulecommand; - 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-awareuseWords,selectContent,describeContentSelection,createBlockMotion,createTargetHighlight,createTextFeedback,textPeerCaret,clearTextPeers,destroyTextPeerCaret,registerPasteTarget; andTextEditornow 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 itunknown_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 viewimport { 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 YjsIt 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 alivetest.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.onSessionhears it change.session.entityis{ id, type, kind?, title, spaceId, parentId }, and for an uploaded file alsomimeandblobHash— what a viewer needs to read the bytes:files.capabilities([blobHash])withfiles.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), ordata-drop-types="Files …"when the shared UI installs its controller. Nested destinations use one container hint; a frame's body is its defaultdata-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' }ordata-drop-hint="always"; usehint: 'hover'for secondary targets such as tab strips. Import@meldly/module-sdk/drop-zones.cssfor non-UI pages; UI CSS includes it. CallacceptDrags(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. Usedisabled,aria-disabled="true"ordata-drop-disabledfor 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/dropevents. For drags originating in Meldly, callacceptDrags(joined.host)once (the UI client's host binding already does). The host relays across origins as ordinarydragenter/dragover/dropevents at the local point. Accept withpreventDefault()anddataTransfer.dropEffect; unaccepted drops remain the host's. Protected hover exposes types, includingFiles, with no bytes. An accepted drop receives realFileobjects indataTransfer.files, structured-cloned without converting bytes to text. Upload throughfiles.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
joinHostmeasures what colour your top edge is painted and Meldly paints the tab the same. Nothing to do;reportSurface: falseturns 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 themKeys 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 testnpm run check # meldly-module check: the starter runs it first in npm run buildmeldly-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
opsdoor,conflictwhen 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, grantedpresence.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 itswarm/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, themeldly-modulecommand; 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'snpm run packuses 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,untranslatedfor 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
./uientry 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
