Formats and Contracts
Everything another program has to agree with: the editor component’s public
API and events, the Studio SDK, the callout-* class token, the authoring
file layout and its sidecars, content normalization and hashing, and the
components catalog. Verified against POC build 213
(20260910T123348).
Contents
- Component public API
- Component events
- The Studio SDK
- The callout class token
- Authoring file layout
- Sidecars, and what publishing never emits
- Panel manifests
- Content normalization and hashing
- Assembly URIs and the catalog
Component public API
Registered synchronously on mount, so the host can drive the component immediately.
| Method | Purpose |
|---|---|
getContent() / setContent(s) |
Read/replace the source. setContent resets history and closes any find session |
undo() / redo() |
Programmatic history navigation |
openFind() |
Open the find bar (same guards as Ctrl/Cmd+F) |
cut() / copy() / paste() |
Clipboard operations on the selection |
replaceRange(start, end, text) |
Replace a span — one undo step |
insertAfter(pos, text) / insertBefore(pos, text) |
Block insertion relative to the line containing pos — one undo step |
wsEdits(text) |
Whole-document tidy edit spans. See below |
setStudentMode() / setInstructorMode() |
Switch view modes |
openGlossaryDefine(term, definition) |
Open the glossary define dialog, prefilled |
getPreviewScroll() / setPreviewScroll(y) |
Preview scroll persistence (used by glossary back-to-reading) |
scrollPreviewToTerm(text) |
Scroll the preview to a <dt> or heading matching text (whitespace-collapsed, case-insensitive) and flash it; returns whether one was found |
Two registration names
root.ayodeMystComponent = { … };
root.ayodeComponent = root.ayodeMystComponent;
ayodeComponent is the uniform name shared by all component types and is
what the canvas prefers; ayodeMystComponent is retained as a fallback for
published builds that predate the alias. New host code should use
ayodeComponent.
wsEdits contract
Returns [{start, end, text}] edit spans, and its three-state return is the
contract:
| Return | Meaning |
|---|---|
null |
The AST bundle has not loaded — protected spans are unknowable. Callers must skip, never guess |
[] |
Already normalized |
| spans | Apply right-to-left; they never overlap by construction |
It is whole-document only; selection-scoped tidying stays internal to the menu command. This is the single shared oracle behind the menu command, the marker layer and the host’s normalization pipeline — which is why they can never disagree. Its scope is wider than its name: see what the tidy pass actually removes.
Component events
Both bubble.
| Event | Fired |
|---|---|
contentchange |
On every content path — typing, menu commands, undo/redo, AI results, programmatic edits. Carries detail.source |
componentrendered |
After each completed preview render cycle |
aitunerequest |
When an Intelligence operation is chosen. See ĀYŌDÈ Intelligence Operations |
contentchange being the single funnel for every content path is what keeps
the find bar’s match counter in step with programmatic edits.
The Studio SDK
window.AyodeStudioSDK is the standardized interface the shell exposes to
every mounted component. Components must consume only this surface, never
shell internals; hosts without it — Student Lab, published lessons —
degrade gracefully.
Four members at version: 1:
glossary
| Member | Purpose |
|---|---|
defineTerm(term, definition) |
Create/update an entry; the first term auto-creates the Glossary panel, then term-ifies all content panels |
linkTerms() |
Re-run term-ification. Resolves true iff any panel source changed. Also runs on content-panel focus loss and is awaited before every save and publish |
getTerm(term) |
Definition lookup; null when absent |
getTerms() |
[{term, definition}] |
subscribe(cb) |
Change notification; returns an unsubscribe function |
openTerm(ref, {previewScroll}) |
Navigate to an entry, remembering where the reader came from |
goBack() |
Back-to-reading: originating panel plus exact preview scroll |
assessment
| Member | Purpose |
|---|---|
hasAssessmentChild() |
Whether the active panel already has a child panel titled assessment |
challengeShell
| Member | Purpose |
|---|---|
listChallenges() |
[{ref, title, program}] for the current lesson |
getAccessToken
How mounted components resolve their JWT. The Merlin Terminal’s fallback
chain tries root.__ayodeAssemblyContext first, then this hook, then
vendor/dataset sources.
Both hasAssessmentChild() and listChallenges() are called
synchronously, at context-menu-build time — they decide a menu label’s verb
and an entry’s enabled state respectively. An implementation that cannot
answer immediately will produce a wrong menu, not a late one.
Design invariants behind the glossary surface
- The Glossary panel’s MyST is the single source of truth — a canonical
{glossary}directive, alphabetized. Everything parses it on demand; hand edits are honored; there is no side data store. - Term references live in the source as explicit
{term}`…`roles, written by term-ification (AST-masked: code, math, links and directive fences are protected; idempotent), never injected at render time. The renderer’s only glossary job is mapping parsed roles to#term-…links. - Term-ification is single-flight: define, focus loss, panel switch, saves and the manual command all serialize through one promise chain.
The callout class token
callout-<slug> on an admonition’s :class: is the single contract for
styling, cursor detection and menu state, across the editor preview, Student
Lab and published lessons.
Consequences that follow from it being the sole predicate:
- A class-less
{warning}or{danger}keeps generic styling everywhere — the branded treatment is opt-in by class, never by directive name. - The Call outs menu disables inside any non-callout admonition, because the token’s absence is what identifies “someone else’s block”.
- Styling is CSS only (
.mc-render .admonition.callout-*), which is why it survives into hosts that run none of the Studio’s JavaScript.
Authoring file layout
Authoring is file-only: the complete lesson, including every assessment, is reconstructable from this tree alone, with no database round-trip. Each panel is a self-contained subdirectory.
<baseDir>/<Lesson>/
<Lesson>-authoring.json panel tree + order + identity
panels/<Panel>/
<Panel>-authoring.json panel manifest
components/<Panel>-authoring.md content (myst / mission panels)
<Panel>-assessment-authoring.json challenge panels — the sidecar
<Panel>-grader-authoring.json program challenges — generator source
Panel directory names are uniquified case-insensitively (Panel, Panel-2,
…), so two panels with the same title cannot collide.
The lesson manifest carries {version: "1", name}, plus:
| Field | Contents |
|---|---|
identity |
lessonEID, lessonVariantEID, missionEID, lessonRealmEID — the realm asserted for lesson-domain calls |
lastPublication |
The publication record the assign wizard targets |
Sidecars, and what publishing never emits
Two authoring-only files sit beside a challenge’s manifest, and both are structurally excluded from publishing — the publish walker never reads them.
| Sidecar | Holds |
|---|---|
<Panel>-assessment-authoring.json |
The question MyST, accepted answers / evaluation config, and assessment metadata (name, required flag) |
<Panel>-grader-authoring.json |
The generator files verbatim, plus runtime, comparator and submission settings |
This is a security property, not a packaging convenience. Because the publish walker never emits these files, questions, answer keys and generator sources cannot be reached through published files at all. Students receive challenge content only through the gated assignment-challenge delivery endpoints, and a generator reaches the grading job only as an uploaded, digest-addressed bundle.
The sidecar’s variant block is shape-identical to a
generated-challenges assessments[].variant, so publish packaging is a
projection rather than a transformation — one less place for the two shapes
to drift.
Panel manifests
{version: "2", name}, with kind present only for non-myst panels, plus a
components array.
For a challenge panel:
{ "version": "2", "name": "…", "kind": "challenge", "challengeRef": "…",
"components": [
{ "name": "assessment-1", "type": "assessment-authoring", "assessmentRef": "…" },
{ "name": "grader-1", "type": "grader-authoring", "graderRef": "…" }
] }
For a content or mission panel the single component is
{name: "myst-1", type: "myst-editor", sourceURI, contentRef, fullPanel: true, zIndex: 1, position: {left: 0, top: 0}, size: null}.
challengeRef is authored and minted once, never regenerated — published
artifacts and workbench directories are addressed by it.
Content normalization and hashing
Challenge dirty detection hashes a canonical tuple. Two layers:
MyST normalization — deliberately conservative, and the same rules the backend applies server-side, so local and server hashes agree:
- CRLF → LF
- strip trailing spaces and tabs per line
- no trailing newlines
Nothing touches interior whitespace, because indentation is significant inside MyST code blocks and directives.
Canonical JSON — object keys sorted at every level; arrays keep their order, because order is meaningful.
SHA-256 over that canonical tuple (normalized question, strategy, locale and the evaluation configuration) gives the local hash.
The server’s contentHash is authoritative. The local hash only powers
the cheap dirty gate that avoids pointless variant creation; a local/server
mismatch after a variant create is surfaced as a canonicalization-drift
warning rather than silently accepted.
Note the interaction with the tidy oracle: saving normalizes panel sources first, so paragraph unwrapping and HTML stripping change the bytes that are then hashed. That is intended.
Assembly URIs and the catalog
urn:ayode:asset:{realmEID}:{userEID}:{assemblyPath}/assembly.json@{version}
- The catalog is
GET /v1/assemblies/componentsand is unauthenticated; only the payloads are JWT-protected. (The older/catalogsuffix hits the greedy assembly reader — do not use it.) - Components live under the publisher’s user zone, not
~, which is why the catalog carries both the realm EID and the publisher EID. - The mounted component must be the version the catalog entry pins, not
whatever manifest is newest at the path. An unpinned
assetURIdegrades to latest. - Catalog fetches carry the asserted realm.
The three assemblies the Studio resolves by name:
| Catalog name | Path fragment |
|---|---|
| Merlin MyST Editor | assembly-components/merlin/content/myst-editor |
| Merlin Challenge Config | …/challenge-config |
| Merlin Terminal | resolved by name for preview mounts |
PUBLISHED_CHALLENGE_BODY is a shared cross-POC contract; asset write
responses carry the stored version in a flat body
({basename, extension, realmEID, version}).