Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch
Embeds

Embed the 3D scene viewport

Frame Nodaro's 3D scene viewport in your app and drive it with postMessage, from the handshake and state messages to edit events, asset transport and limits.

The 3D scene viewport is Nodaro's 3D previsualization viewer, which you can frame inside your own app at /embed/scene3d. It is the same viewer the Nodaro editor uses: a three.js viewport, playback controls, an object list, numeric pose editors and a revision history. The frame is driven entirely by postMessage: your page keeps the scene, and the frame draws it and reports what the user did.

This is not the MiniApp embed. A MiniApp runs a published workflow; the viewport runs nothing. See Embeds for the difference, and 3D scene format for the data it draws.

What the frame does and does not do

ReadsOnly the parentOrigin and channel in its URL, and state messages from that exact origin.
DrawsThe scene you send: the viewport, playback and scrubbing, the object list, the pose editors of each object and of the camera, the revision history and the pending-revision notice. For a baked (version 2) scene: the baked camera, shot navigation, the semantic entities with their identity colors, and the overlay controls.
Sendsready once it listens, then one event per user action, and one asset-request per declared asset of a version 2 scene.
Never doesAuthenticate, read or write storage, call a /v1 endpoint, keep anything across a reload, or move its own view forward after an edit.

The frame is stateless by design. When the user commits an edit, the frame sends you a new, immutable revision, and keeps showing the old one until you send the result back. A frame that ran ahead of its parent could show a revision that never gets saved.

The frame holds no secret and is safe to load in a page you do not fully control. The scene data you send it is only as private as the origin you address it to, so always send to an exact origin; see the parent checklist.

Check that the viewport is available

On a deployment that includes the viewport, the Generate 3D Scene node advertises the scene3d-embed-v1 capability in GET /v1/nodes. Check it before you offer the embedded editor.

The URL

https://app.nodaro.ai/embed/scene3d?parentOrigin=<origin>&channel=<uuid>
ParameterRequiredRule
parentOriginYesThe exact, normalized origin of the framing page: a scheme, a host and an optional port, nothing else. new URL(value).origin must equal the value. http: and https: only, up to 255 characters. A trailing slash, a path, a query, user information, null or any other scheme is refused.
channelYesA fresh random UUID, from crypto.randomUUID(), one per mounted frame. It keeps two viewports on the same page, or a stale frame from a closed dialog, from repainting each other.

MessageEvent.origin arrives normalized by the browser. Requiring the parameter in the same spelling lets the frame compare the two exactly instead of guessing. If a parameter is missing or malformed, the frame shows an error, sends nothing and listens to nothing.

The handshake

  1. Your page adds the frame with its URL.
  2. The frame mounts and starts listening. It sends nodaro:scene3d:ready, and repeats it until you answer.
  3. Your page answers with a nodaro:scene3d:state message. The frame validates the scene and draws it.
  4. When the user acts, the frame sends a nodaro:scene3d:event.
  5. Your page checks expectedRevisionId, applies the event and sends a new state: the new truth.

Send your first state in response to ready, not on the iframe's load event. load fires before the frame's listener is ready, so a message timed on it can be dropped. The frame repeats ready on a short, bounded schedule, which covers a parent that starts listening slightly late. It stops after your first message addressed to it, even one it refuses: a refusal still proves you are listening.

Parent to frame: state

There is one message type, and it carries a full snapshot. There is no partial update: always send the complete state you want drawn.

{
  type: "nodaro:scene3d:state",
  version: 1 | 2,                     // 2 enables baked (version 2) scenes, see below
  channel: string,                    // must equal the channel in the URL
  scenePlan: Scene3DPlan,             // required, validated
  selectedObjectIds?: string[],       // default []
  lockedObjectIds?: string[],         // default []
  history?: Scene3DRevisionEntry[],   // default []
  pendingPlan?: Scene3DPlan,          // default absent
  isGenerating?: boolean,             // default false
  readOnly?: boolean,                 // default TRUE
}

A history entry:

{
  revisionId: string,                     // UUID
  scenePlan: Scene3DPlan,                 // validated like the active plan
  source: "generate" | "edit" | "manual" | "upstream",
  createdAt: string,                      // your timestamp, shown as it is
  changeSummary?: string,
  context?: { prompt?: string },          // only prompt is kept
}

scenePlan is the public Scene3DPlan of the @nodaro/shared package: the same shape a Generate 3D Scene or Edit 3D Scene job returns in output_data.scenePlan.

  • readOnly defaults to true. Silence means look, do not touch. An editable frame must say readOnly: false.
  • context keeps only prompt, which becomes the tooltip of the restore button. Other keys inside context are dropped silently. Unknown keys anywhere else are refused; see Versioning.
  • isGenerating: true only adds a note that a revision is being generated. The scene stays live and, when it is not read-only, editable.
  • pendingPlan covers a job that finished after the user edited. The frame shows a notice and, when editable, two buttons to choose a revision.

What the frame ignores and what it refuses

The frame receivesIt
A message whose event.source is not the framing windowIgnores it, silently.
A message whose event.origin is not parentOriginIgnores it, silently.
Another type, another channel, or a payload that is not an objectIgnores it, silently.
An unknown versionRefuses it, visibly.
A malformed envelope, an unknown key or a list over its limitRefuses it, visibly.
A scenePlan, pendingPlan or any history[].scenePlan that fails validationRefuses it, visibly.

Ignoring is silent, because another frame's traffic is not the user's problem. Refusing is visible, because it was your message that was wrong. A refusal never destroys accepted data: the last accepted snapshot stays on screen under a banner that names the reason, and the next valid state clears it.

Frame to parent: ready and event

The frame posts both messages to the exact parentOrigin of its URL, never to "*".

{
  type: "nodaro:scene3d:ready",
  version: 1,                              // the baseline, always 1
  channel: string,
  protocolVersions: [1, 2],                // every version this frame accepts
  capabilities: {
    assetTransport: true,                  // it can ask you for asset bytes
    sceneSchemaVersions: [1, 2],           // the scene versions it can draw
  },
}

Match ready on type and channel, and read protocolVersions. Do not compare the whole message. version stays 1, so a parent written for version 1 keeps working; everything version 2 adds is announced in fields such a parent does not read.

{
  type: "nodaro:scene3d:event",
  version: 1 | 2,                     // 2 ONLY for edit-operations
  channel: string,
  expectedRevisionId: string | null,
  event:
    | { kind: "plan", plan: Scene3DPlan, changeSummary: string }
    | { kind: "selection", objectIds: string[] }
    | { kind: "locks", objectIds: string[] }
    | { kind: "restore", revisionId: string }
    | { kind: "resolve-pending", adopt: boolean }
    // version 2 scenes only, see "Baked scenes" below
    | { kind: "edit-operations", operations: Scene3DV2EditOperation[], expectedContentHash: string },
}

The envelope says version: 2 only for edit-operations. Every other event kind still says 1, so a version 1 parent never meets an unknown version on a message it understands.

expectedRevisionId is the revision the frame showed when the user acted, the base the action was computed from. Apply the event only if it still equals your active scenePlan.revisionId, and drop it otherwise. That one check turns a click on a snapshot you already replaced into nothing, instead of a silent rollback. It is null only before the frame accepted any state.

kindMeaningWhat you do
planA local edit, such as a numeric pose or a color change, produced a new immutable revision. plan.parentRevisionId is expectedRevisionId. No model ran and nothing was billed.Make plan the active revision, add it to the history and send a new state.
selectionThe user selected or deselected objects.Mirror it, and send state or just remember it.
locksThe user locked or unlocked objects. A locked object is one an edit job must leave byte-identical.Save it and send state.
restoreThe user asked for an earlier revision from the history you sent.Make it active and send state.
resolve-pendingadopt: true uses pendingPlan; false keeps the current plan.Resolve it and send state without pendingPlan.
edit-operationsA version 2 overlay edit, as operations. The frame does not apply it.Apply it with the shared applier, save the result and send the saved plan back. See Edit a baked scene.

Read-only mode

readOnly, which defaults to true, keeps the whole viewer and withholds every write:

Still worksWithheld
Play, pause and scrubNumeric pose commits, for objects and the camera
Selecting in the viewport and in the listObject and background colors
Reading the object list, the lock badges and the revision historyLock toggles
The pending-revision noticeRestore, and the pending-revision buttons

The only event a read-only frame emits is selection. The rule holds both ways: the controls that would change the scene are disabled or hidden, and the frame refuses to emit a change even if a control is driven directly. Read-only is a property of the frame, not a style.

Baked scenes and the asset transport

A version 1 scene is self-contained: primitives, keyframes and a camera, all inside the plan you send. A version 2 scene is baked geometry. Its manifest carries semantic entities, shots, a baked camera track and a list of asset ids with SHA-256 digests, while the bytes themselves stay behind Nodaro's authenticated API.

The frame still has no session and makes no network call. Instead, it asks you for exactly the assets the manifest declares, and you fetch them with your own session.

Turn it on

Send version: 2 on your state message. A version 2 scene on a version: 1 message is refused with scenePlan — this scene uses schema version 2, which needs embed protocol version 2 (asset transport): a version 1 parent has no handler for asset requests, and the frame would wait forever. Version 1 scenes work with either version.

The messages

// frame to parent
{
  type: "nodaro:scene3d:asset-request",
  version: 2,
  channel: string,
  requestId: string,                 // new for each request; send it back unchanged
  revisionId: string,                // plan.revisionId
  assetId: string,                   // an opaque id from plan.assets
  kind: "glb" | "camera-track-json",
  byteLength: number,                // what the manifest declares
  sha256: string,                    // 64 lower-case hex characters
}

// parent to frame, success
{
  type: "nodaro:scene3d:asset-response",
  version: 2,
  channel: string,
  requestId: string,                 // echoed
  revisionId: string,                // echoed
  assetId: string,                   // echoed
  ok: true,
  bytes: ArrayBuffer,                // a structured clone: NOT a URL, NOT base64
}

// parent to frame, failure
{ /* same envelope */ ok: false, error: "short reason" }

revisionId is the current retained revision of the plan. Each retained revision pins all its assets, including bytes it reuses from an earlier revision.

What the frame checks

The frame silently ignores normal traffic that is not for it:

  • A response from another window, another origin or another channel.
  • A requestId that is not outstanding, such as a late answer after a timeout, a duplicate, or bytes nobody asked for.

It fails that one asset, visibly, in these cases:

  • version is not 2, or a field is unknown or missing.
  • revisionId or assetId is not the one requested.
  • The answer is ok: false. The frame shows your error, shortened.
  • bytes is not an ArrayBuffer.
  • The length is not the manifest's byteLength.
  • The SHA-256 digest is not the manifest's digest.

The digest check is the one that matters: it separates "the host delivered some bytes" from "these are the bytes this revision is made of". The renderer checks the digest a second time before parsing, so no path draws unverified geometry.

The frame also bounds itself:

  • At most 4 requests are in flight, and the rest wait in a queue.
  • At most 64 requests and 64 MiB are allowed per revision.
  • Each request has 20 seconds to answer.
  • Every outstanding request is rejected when a new revision arrives or the frame unmounts, so an answer for a scene the user left is never drawn.
  • Identical assets, with the same id and digest, are requested once, so an overlay revision that reuses a GLB does not download it again.

Rules for your page

  1. Authorize against what you sent. Answer only when request.revisionId is the revision you sent (plan.revisionId), and request.assetId is in that plan's assets with the same byteLength and sha256. Otherwise answer ok: false. Skip this check, and your session becomes an oracle: whoever controls the framed page could ask for any revision or asset and read the answer.
  2. Never send a credential. No token, no cookie and no signed URL: a URL that grants access is a bearer token spelled differently. Send bytes.
  3. Post to the frame's exact origin, never to "*".
  4. Do not transfer a buffer you keep. A structured clone copies it; a transfer list would take it away from you.
  5. Drop requests for a revision you have replaced, and keep at most one fetch in flight per revision and asset.
import { createClient } from "@nodaro/sdk"
import type { Scene3DPlanV2 } from "@nodaro/shared"

const client = createClient({ baseUrl: "https://app.nodaro.ai", auth: myAuth })

/** The plan you sent most recently. */
let active: Scene3DPlanV2

window.addEventListener("message", async (event) => {
  if (event.source !== iframe.contentWindow) return
  if (event.origin !== NODARO_ORIGIN) return
  const data = event.data
  if (data?.type !== "nodaro:scene3d:asset-request") return
  if (data.channel !== channel || data.version !== 2) return

  const reply = (body: Record<string, unknown>) =>
    iframe.contentWindow?.postMessage(
      {
        type: "nodaro:scene3d:asset-response",
        version: 2,
        channel,
        requestId: data.requestId,
        revisionId: data.revisionId,
        assetId: data.assetId,
        ...body,
      },
      NODARO_ORIGIN,
    )

  // 1. Authorize: the asset must belong to the plan we sent, and the request
  //    must name the revision that plan pins the bytes to.
  const asset = active.assets.find((a) => a.assetId === data.assetId)
  const pinnedTo = active.revisionId
  if (
    !asset ||
    data.revisionId !== pinnedTo ||
    asset.byteLength !== data.byteLength ||
    asset.sha256 !== data.sha256
  ) {
    reply({ ok: false, error: "unknown asset" })
    return
  }

  // 2. Fetch with OUR session, and send the bytes, never the URL or the token.
  try {
    const bytes = await client.scene3d.assetBytes(pinnedTo, asset)
    reply({ ok: true, bytes })
  } catch (error) {
    reply({ ok: false, error: error instanceof Error ? error.message : "fetch failed" })
  }
})

Edit a baked scene

Version 2 edits are overlays, and the frame never applies one itself. A version 2 revision your server has not stored would look saved while its assets still belong to the revision it came from. So the frame sends the operations and keeps showing the revision you sent:

// event.event
{
  kind: "edit-operations",
  operations: [
    { op: "set-override", override: { kind: "entity-transform", entityId: "hero", space: "local", position: [3, 0.5, 0] } },
  ],
  expectedContentHash: "<64 hex characters>",   // plan.provenance.contentHash
}

Pass all three stale-check values to the shared applier, the same one the API uses, then save the result and send it back:

import { applyScene3DV2EditOperations } from "@nodaro/shared"

const result = await applyScene3DV2EditOperations(active, message.event.operations, {
  expectedRevisionId: message.expectedRevisionId,          // from the envelope
  expectedContentHash: message.event.expectedContentHash,
  lockedObjectIds,
})
if (!result.ok) return showError(result.message)           // stale_revision, locked, ...

An override names only the channels it changes, and the frame builds it that way on purpose. For an asset entity, the node transform in the GLB file is authoritative, so an edit that moved something must not also restate a rotation it never touched. To save an edit without a generation job, call POST /v1/3d-scene/revisions/:revisionId/edits, or client.scene3d.applyEdits() in the SDK; see the 3D scenes API.

Validation and limits

scenePlan, pendingPlan and every history[].scenePlan are parsed with the public scene3DPlanSchema of @nodaro/shared. The check covers the structure and the rules across fields: parent cycles, missing parents, keyframes after the last frame and the duration ceiling. A plan that fails is refused whole.

LimitValue
history entries12
selectedObjectIds or lockedObjectIds entries100
Length of an object id64 characters
Length of changeSummary and context.prompt2,000 characters
Length of createdAt64 characters
Length of parentOrigin255 characters
Frame width and height100 to 2,560 px on each axis
Objects per scene100
Keyframes per object or per camera track240
Entities in a version 2 scene100
Assets, shots and overrides in a version 2 scene64, 32 and 200
Asset bytes a version 2 scene may declare64 MiB

The frame checks the list lengths before it parses anything, so an oversized payload is refused without being read. A version 2 manifest is a promise about bytes, so the declared totals are checked before a single asset is requested. A frame larger than 1,920 px on its longest side renders to MP4 at a higher price; see Render Video.

Parent checklist

The frame guards its own inbox. Only your page can guard yours.

  1. Create a fresh channel per frame with crypto.randomUUID(), and keep it.
  2. Build the URL with your own exact origin, from window.location.origin, not from a string you assembled.
  3. Check four things on every message you receive.
    • event.source === iframe.contentWindow.
    • event.origin is exactly the Nodaro origin you framed.
    • data.channel is your channel.
    • data.version is a version you implement: 1, or 2 if you serve assets.
  4. Check expectedRevisionId against your active revision before you apply a plan, restore or resolve-pending event, and drop the event if it does not match.
  5. Validate plan again with scene3DPlanSchema before you store it. The frame validates what it draws; you are responsible for what you save.
  6. Send state on ready, and again after every change you accept.
  7. Never post to "*". Address the Nodaro origin explicitly.
  8. Put nothing secret in a message. The protocol carries scene geometry and revision ids: no tokens, no user identifiers, and no URL you would not put in a scene reference.
  9. Remove the listener when the dialog closes. A stale listener plus a reused channel is how two dialogs start answering each other.
  10. If you serve version 2 scenes, authorize every asset request against the plan you sent, as described in Rules for your page.

Worked example

import { scene3DPlanSchema, type Scene3DPlan } from "@nodaro/shared"

const NODARO_ORIGIN = "https://app.nodaro.ai"
const channel = crypto.randomUUID()

const iframe = document.createElement("iframe")
iframe.src =
  `${NODARO_ORIGIN}/embed/scene3d` +
  `?parentOrigin=${encodeURIComponent(window.location.origin)}` +
  `&channel=${channel}`
iframe.allow = "" // the frame needs no permissions

let active: Scene3DPlan = initialPlan                // your current revision
let history: RevisionEntry[] = []                    // newest last, at most 12

function pushState() {
  iframe.contentWindow?.postMessage(
    {
      type: "nodaro:scene3d:state",
      version: 1,
      channel,
      scenePlan: active,
      selectedObjectIds: selection,
      lockedObjectIds: locks,
      history: history.slice(-12),
      readOnly: false,          // leave it out and the frame is view-only
    },
    NODARO_ORIGIN,              // never "*"
  )
}

function onMessage(e: MessageEvent) {
  if (e.source !== iframe.contentWindow) return
  if (e.origin !== NODARO_ORIGIN) return
  const data = e.data
  if (!data || typeof data !== "object") return
  if (data.channel !== channel || data.version !== 1) return

  if (data.type === "nodaro:scene3d:ready") {
    pushState()                 // the frame listens: send the truth
    return
  }
  if (data.type !== "nodaro:scene3d:event") return

  // The action was computed from a revision we may have replaced already.
  const mutates = data.event.kind !== "selection"
  if (mutates && data.expectedRevisionId !== active.revisionId) return

  switch (data.event.kind) {
    case "selection":
      selection = data.event.objectIds
      return                    // no new state needed; the frame shows it already
    case "locks":
      locks = data.event.objectIds
      break
    case "plan": {
      // Trust nothing you save.
      const parsed = scene3DPlanSchema.safeParse(data.event.plan)
      if (!parsed.success) return
      active = parsed.data
      history = [...history, {
        revisionId: active.revisionId,
        scenePlan: active,
        source: "manual",
        changeSummary: data.event.changeSummary,
        createdAt: new Date().toISOString(),
      }].slice(-12)
      break
    }
    case "restore": {
      const entry = history.find((h) => h.revisionId === data.event.revisionId)
      if (!entry) return
      active = entry.scenePlan
      break
    }
    case "resolve-pending":
      active = data.event.adopt ? pending! : active
      pending = undefined
      break
  }
  pushState()
}

window.addEventListener("message", onMessage)
document.body.appendChild(iframe)

// On teardown:
//   window.removeEventListener("message", onMessage)
//   iframe.remove()

Generating and editing scenes with a model, and rendering a scene to MP4, are ordinary API jobs: see Generate 3D Scene, Edit 3D Scene and Render Video. The viewport is only the viewer: it never starts a job and never spends anything.

Versioning

version: 1 is the frozen first contract. The state envelope is strict: an unknown top-level key, or an unknown key inside a history entry, is refused rather than ignored, so a parent and a frame never half-agree about what a message means. The protocol grows by raising version, and a frame that does not implement a version refuses the message visibly instead of guessing.

version: 2 adds to version 1 and replaces nothing. It brings baked scenes, the asset transport and the edit-operations event, and a parent that speaks only version 1 keeps working unchanged. The frame announces what it can do in ready.protocolVersions and ready.capabilities: read those fields instead of assuming, which keeps the next version additive too. If you need a field that does not exist, open an issue in the public repository rather than sending it anyway: a refused message is the contract working.

Frequently asked questions

Last updated on

On this page