بنقرة واحدة
write-overlay
Write a custom JSX overlay component and add it to the project's overlay track.
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Write a custom JSX overlay component and add it to the project's overlay track.
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
| name | write-overlay |
| description | Write a custom JSX overlay component and add it to the project's overlay track. |
An overlay is a React component rendered frame-by-frame by Puppeteer, composited over the footage at a specific timestamp. All overlays are custom JSX — there are no built-in templates.
Custom overlay JSX runs in a sandboxed evaluator. All identifiers below are injected as globals:
| Identifier | Type | Description |
|---|---|---|
frame | number | Current frame number (0 → duration-1). Drives all animation. |
fps | number | Output frame rate |
duration | number | Total frames this overlay is visible for |
props | object | The props object from the project.json item |
interpolate | function | Map a frame number to any output value |
spring | function | Physics-based easing (0 → 1) |
Ph | object | All Phosphor Icons — e.g. Ph.House, Ph.ArrowRight |
FaIcon | component | FontAwesomeIcon renderer — use with FaSolid / FaBrands icon objects |
FaSolid | object | All FA Free Solid icon objects — e.g. FaSolid.faHouse |
FaBrands | object | All FA Free Brands icons — e.g. FaBrands.faGithub |
THREE | namespace | All Three.js primitives — THREE.Vector3, THREE.MathUtils, etc. Only reach for it when you genuinely need 3D — see "3D / Three.js" section. |
Canvas | component | @react-three/fiber Canvas. Always pass frameloop="never" and mount a useThreeFrame() child — see "3D / Three.js" section. |
useThreeFrame | hook | Bridges r3f to Montaj's frame-stepped renderer. Mount exactly once inside any <Canvas>. |
No imports. All import statements are stripped before evaluation. Do not import anything — use the globals above instead.
All calls to interpolate, spring, and any read of frame, fps, duration, or props must be inside the component function body. The module's top-level code runs before the render shim sets up these globals — calling them outside a function will throw interpolate is not defined and crash the entire render.
// WRONG — crashes at render time
const opacity = interpolate(frame, [0, 10], [0, 1])
export default function Hook() { ... }
// CORRECT — inside the component, runs each frame
export default function Hook() {
const opacity = interpolate(frame, [0, 10], [0, 1])
return <div style={{ opacity }}>...</div>
}
Pure helper functions that receive their values as arguments are fine at the top level, as long as they don't call globals at definition time:
// Fine — interpolate is only called when the function is invoked (inside the component)
const itemStyle = (show) => ({
opacity: show,
transform: `translateY(${interpolate(show, [0, 1], [20, 0])}px)`,
})
export default function List() {
const show = spring({ frame, fps, stiffness: 300, damping: 24 })
return <div style={itemStyle(show)}>...</div>
}
Carousel text overlays follow a stricter contract. For carousel projects, every text-bearing overlay must accept its font size, family, weight, style, color, alignment, transform, and background as props with string defaults — see skill
editable-text. The "go large — for video" guidance below, and the hardcoded-style style of theHookexample, do not apply to carousel editable-text overlays.
The default aesthetic is plain bold text directly on video — no card, no background, just a text shadow for legibility. Big text (96–160px) that covers the footage, including the speaker's face if needed.
// overlays/hook.jsx — plain text on video, no background
export default function Hook() {
const progress = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' })
const slideY = interpolate(frame, [0, 10], [40, 0], { extrapolateRight: 'clamp' })
return (
<div style={{
position: 'absolute', bottom: 180, left: 48, right: 48,
opacity: progress,
transform: `translateY(${slideY}px)`,
}}>
<div style={{
fontFamily: 'Anton, Impact, sans-serif', fontSize: 120, fontWeight: 900,
color: '#fff', lineHeight: 1.05, letterSpacing: '-1px',
textShadow: '0 2px 24px rgba(0,0,0,0.9), 0 0 60px rgba(0,0,0,0.5)',
textTransform: 'uppercase',
}}>
{props.text}
</div>
</div>
)
}
Only add a card or background when the prompt explicitly asks, or when a specific overlay type genuinely requires it (e.g. a logo lockup, an opaque title card). When you do need a background, prefer a solid semi-transparent color over backdropFilter: blur() — see the track-splitting section below.
useState, useEffect, etc. are not supported in the overlay component itself. The render shim drives re-renders by calling flushSync externally each frame; the component must be a pure function of its props/globals.frame. No setTimeout, setInterval, CSS animation, or transition.background on the root element; it will obscure whatever is beneath it."opaque": true is set on the item in project.json, the root element's CSS controls the entire frame. You may freely set background, gradients, images, or any CSS on the root. Use this for full-frame covers, title cards, and animation sections.project.settings.resolution). The Puppeteer viewport is always 1080-short-edge regardless of output resolution; the renderer upscales to the final video dimensions at compose time. Place elements with position: absolute. Author all fontSize, padding, and width values at 1080-design coordinates — they have one consistent meaning across every resolution the project might render at.backdropFilter caution — backdrop-filter: blur(...) causes Chrome to create a separate GPU compositor layer that can be cached and replayed as a stale frame during rendering. Avoid putting backdrop-filter on any element whose children animate — the blur container will flash or freeze. See the track-splitting guidance below.The most reliable way to use frosted-glass / blurred card backgrounds is to put the background on a separate, lower track and the animated content on a higher track. The render pipeline composites tracks in order, so the content renders on top.
Why this works: A background card with backdrop-filter is essentially static — it fades in, then stays put. When Chrome's headless compositor caches the GPU layer for it, the cache is correct (the layer genuinely hasn't changed). The content overlay on the higher track has no backdrop-filter, so there's no caching issue and animations render cleanly every frame.
When to split:
| Background behavior | Animated content | Verdict |
|---|---|---|
| Static or simple fade only | Any — text, icons, logos staggering in | Split |
| Shakes, bounces, or translates together with content | Content must move with the background | Keep together (no backdrop-filter, use solid background instead) |
How to split in project.json:
{
"tracks": [
[],
[
{
"id": "ov-card-bg",
"type": "overlay",
"src": "/path/overlays/card-bg.jsx",
"start": 2.0,
"end": 6.0
}
],
[
{
"id": "ov-card-content",
"type": "overlay",
"src": "/path/overlays/card-content.jsx",
"start": 2.0,
"end": 6.0
}
]
]
}
Background component — no animated children:
// overlays/card-bg.jsx
// Just a frosted card that fades in. No children that animate opacity.
const opacity = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' })
export default function CardBg() {
return (
<div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center', opacity }}>
<div style={{
background: 'rgba(0,0,0,0.84)',
backdropFilter: 'blur(24px)',
borderRadius: 36,
padding: '44px 72px',
border: '1px solid rgba(255,255,255,0.10)',
minWidth: 560,
minHeight: 200,
}} />
</div>
)
}
Content component — no backdrop-filter:
// overlays/card-content.jsx
// Animated items rendered on top of the background card.
const s1 = spring({ frame: Math.max(0, frame - 4), fps, stiffness: 300, damping: 24 })
export default function CardContent() {
return (
<div style={{ position: 'absolute', bottom: 340, left: 0, right: 0, display: 'flex', justifyContent: 'center' }}>
<div style={{ padding: '44px 72px', minWidth: 560 }}>
<div style={{ opacity: Math.min(1, s1 * 2.5), transform: `translateX(${interpolate(s1, [0, 1], [-24, 0])}px)` }}>
<Ph.CheckCircle size={52} weight="fill" color="#34d399" />
</div>
</div>
</div>
)
}
When you can't split (background and content animate together as one unit — e.g., a card that shakes on impact), skip backdrop-filter entirely and use a solid or semi-transparent background instead:
// Instead of backdropFilter: 'blur(24px)'
background: 'rgba(10,10,10,0.88)' // solid dark — visually similar, no GPU layer caching
interpolate(frame, inputRange, outputRange, options?)Maps a frame number to any output value. Clamps at both ends by default.
// Fade in over frames 0–15
const opacity = interpolate(frame, [0, 15], [0, 1])
// Fade in then out
const fadeIn = interpolate(frame, [0, 15], [0, 1])
const fadeOut = interpolate(frame, [duration - 15, duration], [1, 0])
const opacity = Math.min(fadeIn, fadeOut)
// Slide in from left
const x = interpolate(frame, [0, 20], [-200, 0], { extrapolateRight: 'clamp' })
Options: extrapolate, extrapolateLeft, extrapolateRight — each 'clamp' (default) or 'extend'.
spring({ frame, fps, mass?, stiffness?, damping?, initialVelocity? })Returns a 0 → 1 value following spring physics. Overshoots and settles naturally.
const scale = spring({ frame, fps, stiffness: 120, damping: 14 })
// transform: `scale(${scale})`
Defaults: mass: 1, stiffness: 100, damping: 10.
Use icons instead of emojis unless the prompt explicitly asks for emojis. Icons scale cleanly, render crisply at any resolution, and look intentional.
PhBrowse at phosphoricons.com. Over 9000 icons, six weights: regular (default), bold, fill, duotone, light, thin.
// Basic usage
<Ph.House size={48} color="white" />
// With weight
<Ph.ArrowRight size={32} color="#a78bfa" weight="bold" />
<Ph.Star size={40} color="#fbbf24" weight="fill" />
// In a card row
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Ph.CheckCircle size={36} color="#34d399" weight="fill" />
<span style={{ fontFamily: 'Inter, sans-serif', fontSize: 24, color: 'white' }}>Feature unlocked</span>
</div>
FaIcon + FaSolid / FaBrandsFaIcon is the renderer. FaSolid has general-purpose icons; FaBrands has logos (GitHub, YouTube, X, etc.).
// Solid icon
<FaIcon icon={FaSolid.faCode} style={{ fontSize: 48, color: 'white' }} />
// Brand logo
<FaIcon icon={FaBrands.faGithub} style={{ fontSize: 48, color: 'white' }} />
// Sized via style
<FaIcon icon={FaSolid.faBolt} style={{ fontSize: 36, color: '#fbbf24' }} />
The render host's system fonts are always available and preferred for performance — they avoid the network fetch entirely. On macOS render hosts (today's default) you can rely on Helvetica, Arial, Georgia, Times, Courier, Impact, and the system-ui / -apple-system generic stacks. Inter is not a macOS system font — use a Google Font declaration for it.
To use a Google Font, declare it on the overlay item in project.json with a googleFonts array:
{
"id": "ov-hook",
"type": "overlay",
"src": "/path/to/overlays/hook.jsx",
"start": 0.0,
"end": 5.0,
"googleFonts": ["Anton", "Playfair+Display:ital@1"]
}
The render engine injects the font stylesheet into the page <head> before any component code runs, so the font is fully loaded at frame 0.
Do not use @import url(...) inside the JSX. A dynamically-injected @import fires after the page loads — the font fetch is still in flight when the next overlay's page initialises, breaking its window.__setFrame setup. Always declare fonts in googleFonts instead, and reference the family name directly in styles:
// In your JSX — just use the family name, no @import
fontFamily: '"Anton", Impact, sans-serif'
fontFamily: '"Playfair Display", Georgia, serif'
Format: FamilyName for regular, FamilyName:ital@1 for italic, FamilyName:wght@700 for a specific weight. Each family is a separate array entry.
System font fallbacks for common Google Fonts:
| Google Font | System fallback |
|---|---|
| Anton | Impact |
| Playfair Display | Georgia |
| Oswald | Arial Narrow |
| Roboto / Inter | system-ui, sans-serif |
If visual fidelity isn't critical, the system fallback avoids the network fetch entirely.
Overlay JSX can use Three.js for real 3D content via @react-three/fiber. Reach for it when you need depth, particles, shader effects, 3D text, or geometry that can't be faked in 2D. Do not use it for things 2D HTML can do — rotations, fades, slides, gradients are all cheaper and simpler in CSS.
Preview behavior — RAF-driven, not frame-stepped. Three.js overlays render in both the live UI preview and the final rendered MP4. The two paths share globals and library versions via the montaj-overlay-runtime package, so what you see in the editor matches the render output visually. One difference to be aware of: in preview the 3D content animates via r3f's own requestAnimationFrame loop (the preview-context Canvas wrapper overrides frameloop="never" → "always" automatically), so motion is smooth but not perfectly frame-accurate to the scrubbed video position. In render, frameloop="never" is honored and the shim drives gl.render() synchronously each frame.
<Canvas frameloop="never">. The default r3f Canvas runs its own requestAnimationFrame loop, which is incompatible with Montaj's frame-stepped renderer — Puppeteer would screenshot arbitrary moments. frameloop="never" disables r3f's loop and lets the render shim drive each frame synchronously.useThreeFrame() exactly once inside the Canvas. This hook registers the synchronous render trigger the shim calls every frame. Without it, Three never draws.Convention: put a tiny <FrameBridge /> child component at the top of the Canvas that calls useThreeFrame() and returns null.
frame, never useFramer3f's useFrame hook is tied to the internal animation loop we've disabled. It does not work. Compute transforms inline from the frame global, the same way 2D overlays do:
export default function ThreeCube() {
const t = frame / fps
const rotX = t * Math.PI // half-turn per second
const rotY = t * Math.PI * 0.7
const pulse = 1 + 0.08 * Math.sin(t * 4)
return (
<Canvas
frameloop="never"
style={{ position: 'absolute', inset: 0 }}
camera={{ position: [0, 0, 5], fov: 50 }}
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: true }}
>
<FrameBridge />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 5, 5]} intensity={1.2} />
<mesh rotation={[rotX, rotY, 0]} scale={[pulse, pulse, pulse]}>
<boxGeometry args={[1.6, 1.6, 1.6]} />
<meshStandardMaterial color="#3b82f6" metalness={0.3} roughness={0.35} />
</mesh>
</Canvas>
)
}
function FrameBridge() {
useThreeFrame()
return null
}
Textures, GLTFs, and any other resource that r3f loads via Suspense will not load in time for frame 0 — the renderer doesn't wait for Suspense the way it waits for document.fonts.ready. Stick to:
<boxGeometry>, <sphereGeometry>, <planeGeometry>, <cylinderGeometry>, <torusGeometry>, <icosahedronGeometry>, etc.<meshStandardMaterial> / <meshBasicMaterial> with color (no map, normalMap, etc.)<ambientLight>, <directionalLight>, <pointLight>, <spotLight>THREE global (THREE.MathUtils.lerp, THREE.Vector3, etc.)If you need a texture, render it on a 2D HTML overlay layered above the Canvas track instead.
<Canvas> fills its parent. Two patterns work:
Full-frame Canvas — apply style={{ position: 'absolute', inset: 0 }} directly to <Canvas> so it covers the whole 1080×1920 design canvas. Use when 3D content should occupy the entire frame or be anchored relative to the camera (e.g. a particle field).
Positioned Canvas — wrap <Canvas> in a position: absolute div with explicit top/left/right/height (or bottom), and set style={{ width: '100%', height: '100%' }} on the Canvas itself. Use when 3D content should sit in a specific region — e.g. a 3D logo lockup in the lower-third while a font overlay sits up top.
<div style={{ position: 'absolute', top: 1100, left: 0, right: 0, height: 500 }}>
<Canvas frameloop="never" style={{ width: '100%', height: '100%' }} ...>
<FrameBridge />
...
</Canvas>
</div>
Make sure gl={{ alpha: true }} is set — the Canvas DOM element defaults to opaque, which would paint a black or white box over the underlying footage. With alpha: true + the renderer's default transparent page background, only the drawn 3D geometry shows up; the rest passes through to whatever is beneath.
For text labels alongside 3D content, render them as a 2D HTML overlay on a separate track rather than as 3D text inside the Canvas. HTML text is sharper, cheaper, and supports the existing Google Fonts pipeline.
A known-good minimal overlay lives at tests/fixtures/overlays/three-cube.jsx — it's the same file the render smoke test (tests/test_render_three.py) uses. Copy from it when starting a new 3D overlay; everything in it is checked end-to-end by CI.
Three.js + r3f add ~250 KB to an overlay segment's bundle after esbuild tree-shakes. Overlays that don't use <Canvas> pay zero cost. Don't reach for Three "just in case" — use it only when the result genuinely needs 3D.
Overlay JSX can use SVG-based charts via Recharts. Available globals: BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer.
Charts work in carousel slides today. Video overlays don't have a chart story yet — see follow-up plans. The carousel renderer screenshots SVG directly, so no special contract is required.
Set isAnimationActive={false} on every chart primitive (<Bar>, <Line>, <Pie>, etc.). Recharts animates by default; without this, the carousel renderer captures a mid-animation frame and the chart looks half-drawn. If you see fuzzy ticks or a half-faded legend in the rendered PNG, also pass isAnimationActive={false} to <XAxis> / <YAxis> / <Legend> defensively.
<ResponsiveContainer><ResponsiveContainer> measures via ResizeObserver, which is asynchronous; the Puppeteer screenshot can fire before the first observer callback, producing a blank chart. Instead, take boxWidth / boxHeight as props (the slide wrapper passes them in automatically) and use them as explicit width / height on the chart root: <BarChart width={innerW} height={innerH} ...>.
Three system overlay templates ship today: bar-chart, line-chart, pie-chart. For one-off data viz, write a custom overlay using the same globals. For variants likely to be reused (area, scatter, donut-vs-pie variants beyond innerRadius), promote them to system overlays so they show up in the property panel for everyone.
Place overlay items in tracks[1+] in project.json. Each item must have type: "overlay" and a src path pointing to the JSX file. All custom data goes inside props.
{
"tracks": [
[],
[
{
"id": "ov-hook",
"type": "overlay",
"src": "/abs/path/to/project/overlays/hook.jsx",
"start": 0.0,
"end": 3.0,
"props": {
"text": "She built an AI employee"
}
},
{
"id": "ov-logo",
"type": "overlay",
"src": "/abs/path/to/project/overlays/logo.jsx",
"start": 0.0,
"end": 999.0,
"props": {
"logoSrc": "/abs/path/to/project/assets/logo.png"
}
}
]
]
}
| Field | Required | Description |
|---|---|---|
id | yes | Unique identifier within the track |
type | yes | Always "overlay" for JSX overlays |
src | yes | Absolute path to the JSX file |
start | yes | Start time in output video (seconds) |
end | yes | End time in output video (seconds) |
props | no | Arbitrary data passed through to the component as the props global |
googleFonts | no | Google Font families to load before render (e.g. ["Anton", "Playfair+Display:ital@1"]). See Custom fonts section. |
Use absolute paths for src. Relative paths are resolved from project.json location, but absolute paths are unambiguous.
Assets (logos, images) are declared in project.assets. Reference them by passing their src path in props, then use it in the component:
{
"id": "ov-logo",
"type": "overlay",
"src": "/path/to/overlays/logo.jsx",
"start": 0.0,
"end": 30.0,
"props": { "src": "/path/to/assets/logo.png" }
}
// overlays/logo.jsx
const opacity = interpolate(frame, [0, 6], [0, 1])
export default function Logo() {
return (
<img
src={props.src}
style={{
position: 'absolute', top: 40, right: 40,
width: 80, opacity,
}}
/>
)
}
Reference assets by their workspace path (e.g. /abs/path/to/project/assets/logo.png). How that path is resolved for preview or render is the interface's concern — the component always receives a usable URL for the path it was given.
The Montaj UI has an Overlays tab that gives a real-time animated preview of every overlay in the project — no render needed.
.jsx file; latency is typically under a secondprops (e.g. logoSrc, src) are proxied automatically — images and logos resolve correctly in the preview even though they are absolute local pathsUse this tab to validate motion, timing, and asset rendering before committing to a full render.
When a workflow calls for several overlays, write them concurrently — each JSX file is independent.
Common overlay set for a social reel:
Ph.* or FaIcon for visual symbols. Emojis render inconsistently across platforms and look low-effort. Only use emojis if the prompt explicitly asks for them.carousel §6 (Typography). For other static formats, default to ~32–48px body, ~52–80px headline, and size down further as line length grows.textShadow for legibility is the house style. No dark cards, no frosted glass, no semi-transparent boxes unless the prompt asks. A well-placed textShadow works on any footage.bottom: 350 or higher, or anchor from the top instead.right: 200 or use left-anchored layout.Write all of your overlay JSX first. Then sample them in a single batch pass — do not sample after each file. Per-file sampling stalls authoring and spins up a fresh Puppeteer process each time; one pass at the end over the finished set is faster and just as safe, since nothing downstream consumes an overlay until you save the project with the whole batch.
Once every JSX file is written, loop over them in one pass — for each JSX file, run step sample_overlay with args { overlay, out, measure: true, google_fonts, props }:
overlay — absolute path to the JSX fileout — absolute path where the step writes the sample PNG (required)google_fonts — the overlay's declared googleFonts value (snake_case step arg)props — representative props for the overlayPass each overlay's declared googleFonts (and representative props) so the step measures with the real render-time font — see the Syne case study below.
The step renders the overlay through the same Puppeteer path the production renderer uses and returns:
{
"pngPath": "/tmp/sample-check.png",
"measurements": {
"anyOverflow": false,
"texts": [...],
"viewport": { "w": 1080, "h": 1920 }
}
}
measurements.anyOverflow is the go/no-go signal. If it is true, at least one text element extends past the 1080×1920 design canvas and will be visually clipped in the final render. Inspect measurements.texts[] for per-element detail: bbox gives the element's position and dimensions, and overflow.{left,right,top,bottom} gives the pixel overshoot on each edge.
Two categories of false positives to rule out before fixing:
transform: rotate() or transform: scale() — getBoundingClientRect() returns the axis-aligned bounding box of the transformed element, which is wider and/or taller than the un-rotated element. A 1000×100 banner rotated 45° reports a ~777×777 bbox and will look like it overflows even when it doesn't. Check the transform field on the flagged element. If it's not matrix(1, 0, 0, 1, 0, 0) (the identity), the overflow signal is unreliable for that element.clippingAncestor — an element flagged for overflow may be the child of a container with overflow: hidden (animation sections that clip entering/exiting elements use exactly this). If the clippingAncestor field is non-null, the element is intentionally clipped. Compute intersect(elementBbox, clippingAncestor.bbox) to check effective overflow if you want to be precise.Do not ship an overlay JSX until anyOverflow is false — or until you have verified each overflowing element is covered by one of the two false-positive cases above.
When the Montaj editor preview shows a layout that fits, but the rendered video shows clipped text, the cause is almost always a font-width mismatch. The editor preview falls back to sans-serif when a Google Font is not installed locally. The renderer loads the overlay's declared googleFonts faithfully via the Google Fonts CDN before rendering frame 0.
Display fonts like Syne 800 are 60–70% wider than typical sans-serif fallbacks at the same px size. Concrete example: "RECURSIVE" at fontSize: 160 measures ~933 px wide in fallback sans-serif, but ~1594 px wide in Syne 800 — 514 px of right-edge overflow on a 1080-wide canvas. The editor looked fine; the render was completely clipped.
Any overlay that declares a googleFonts entry must go through the end-of-authoring sample pass with measure: true and that same googleFonts spec, before you save the project with the batch. The preview cannot tell you whether the text fits in the render. The sample can.
You MUST use this whenever the user asks for video editing work. Use it when video-related tasks are brought up. Editing, analyzing video, or transcribing videos
Find and download real images from the web — people, logos, brand/event stills, B-roll — to add as overlay image cards or project assets. Load when the prompt asks to source or insert images (e.g. 'add a photo of X', 'find images of the IPO', 'pull a shot of the factory').
Agent-authored workflow task: analyze transcripts across all clips, pick ONE best take per script section, discard all others. Load this when you hit montaj/select_takes in a workflow.
Agent-authored workflow task: transcribe-and-sample one long-form horizontal source, pick N self-contained clip windows, and fan each out into its own vertical (9:16) clip project with the chosen framing. Load this when you hit montaj/find_clips in a workflow.
The shared vocabulary that domain skills use to name Montaj operations. Domain skills phrase every Montaj interaction as one of these verbs; an interface skill defines how each verb is actually performed. Load this to learn the canonical verbs before authoring or reading a domain skill.
The native interface — how the _contract verbs are performed when Montaj runs locally, either via the HTTP server (montaj serve) or the headless CLI. The dispatcher loads this after it detects HTTP or CLI mode; domain skills never read it directly. Covers step invocation, project read/save, file read/write, logging, GET-before-save discipline, workspace resolution, and image-step credentials.