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

Embed a MiniApp

Run a published Nodaro MiniApp in your product with its embed code, or with your own form on the REST API, from reading its inputs to runs, results and errors.

To embed a MiniApp is to run an already-published Nodaro app from your own product. You can paste the app's embed code into a page, or build your own form on the REST API: read the app's inputs, start a run, poll it and show the result. The app itself stays in Nodaro; your product collects the inputs and displays the outputs.

Two ways to embed

Embed codeYour own interface
EffortPaste one iframe tag.Build a form and a small server route.
LookThe Nodaro app view.Your own design.
Who the run acts asThe viewer, with their own Nodaro session.The account behind your token.

Paste the embed code

Open the app's embed settings

In Nodaro, open MiniApps, choose My MiniApps, and click Embed on the app's card.

Allow your domain

Under Allowed Embed Domains, type your site's origin, such as https://example.com, and click Add. Embedding is blocked until at least one domain is added.

Copy the code into your page

Click Copy Embed Code and paste the tag into your page:

<iframe src="https://app.nodaro.ai/embed/your-app-slug" width="100%" height="600" frameborder="0" allow="clipboard-write"></iframe>

Build your own interface

The rest of this page builds a custom web or mobile interface for a published app on the REST API. It is self-contained, so you can also hand it to an AI code generator: add .md to this page's URL for a Markdown copy.

Three things to know first

Base URLhttps://app.nodaro.ai on Nodaro Cloud, or the address of your own install.
App identifierThe slug: the last part of the published app's URL. For https://app.nodaro.ai/app/my-cool-app, the slug is my-cool-app.
Run shapeAsynchronous. POST /v1/app/{slug}/run answers at once with a runId, and you poll GET /v1/app/{slug}/runs/{runId} for the result. The run does not call you back.

Two public endpoints, which need no token, tell you everything you need for the form:

  • GET /v1/app/{slug} returns the app's inputs and metadata.
  • GET /v1/nodes/{type} returns the fields of one node type.

Step 1: Read the app's inputs

curl https://app.nodaro.ai/v1/app/<slug>

The fields that matter for your interface:

{
  "id": "uuid",
  "name": "Headline Generator",
  "description": "...",
  "iconUrl": "https://...",
  "version": 3,                        // the latest version
  "estimatedCredits": 5,               // the credits one run costs
  "maxRunsPerUserPerDay": null,        // or a number
  "thumbnailNodeId": "node-abc",       // the node whose output is the main result
  "snapshotNodes": [                   // the workflow's nodes
    {
      "id": "node-abc",
      "type": "generate-image",        // use it in step 2
      "data": {
        "prompt": "default prompt",    // the current value of each field
        "aspectRatio": "1:1"
      }
    }
  ],
  "snapshotEdges": [ /* the connections, rarely needed by your interface */ ],
  "snapshotSettings": {
    "presentationSettings": {
      "inputItems": [                  // the form schema
        { "type": "field", "id": "item-1", "nodeId": "node-abc", "field": "prompt", "allowedValues": null },
        { "type": "field", "id": "item-2", "nodeId": "node-abc", "field": "aspectRatio", "allowedValues": ["1:1", "16:9", "9:16"] }
      ]
    }
  },
  "versions": [{ "version": 3, "id": "...", "createdAt": "..." }]
}

inputItems is the form the publisher designed. Walk it, go into every group, and collect every field item: that is your form.

typeRender it as
fieldA form input. Read nodeId, field and the optional allowedValues.
nodeThe whole node's default block. Skip it in a custom form, or render every field of the node.
outputA live preview of a result. Ignore it while you build the form, and show it after the run.
richtextStatic Markdown the publisher wrote. Render it as it is.
groupA container with its own items. Walk into it; groups do not nest.

Step 2: Learn each field's type

inputItems gives you pairs of nodeId and field, but no field type: text, number, choice. Look up the node type to learn more:

curl https://app.nodaro.ai/v1/nodes/generate-image
{
  "data": {
    "type": "generate-image",
    "label": "Generate Image",
    "category": "ai-image",
    "description": "...",
    "outputType": "image",
    "providers": ["nano-banana-pro", "gpt-image-2", "flux"],   // shortened
    "capabilities": ["supports-reference-image", "supports-negative-prompt"]
  }
}

Not every node descriptor has a complete inputSchema. When it is missing, infer the control with these rules, in order:

  1. The input item has allowedValues: a select with those options.
  2. The current value in snapshotNodes[i].data[field] is a boolean: a toggle.
  3. The current value is a number: a number input, or a slider when the field name ends in duration, intensity, strength, scale or temperature.
  4. The current value is a string, and the field name is prompt, description, text, content, caption or message: a multi-line text area.
  5. The current value is a URL, and the node type starts with upload-: a file upload that submits the public URL of the file.
  6. Anything else: a single-line text input.

These field names cover most exposed fields:

Field nameControlNotes
prompt, negativePrompt, text, descriptionText area
model, provider, voice, style, toneSelectOptions come from allowedValues or providers.
aspectRatioSelectUsually 1:1, 16:9, 9:16, 4:3 and 3:4.
resolutionSelectUsually 1K, 2K and 4K, or 720p, 1080p and 4k.
qualitySelectUsually medium and high.
duration, nFrames, seed, temperatureNumber
enableTranslation, addAudio, headless, loopToggle
imageUrl, videoUrl, audioUrl, referenceImageURLUpload the file first, then submit its URL.

Lottie slot fields

A Motion Graphics node on the Lottie engine can expose its named slots, such as colors, texts and numbers, as app inputs. They appear as field items whose field starts with slot:, such as slot:primaryColor. Users change them on every run for 0 credits: when an app exposes slot fields, every run reuses the published animation and only swaps the slot values.

Slot valueControlValue to submit
A color, as an RGBA array of numbers from 0 to 1Color pickerA hex string, such as "#00ff00"
A stringText inputThe string
A numberSliderThe number

A slot value is not a plain node field: it lives in the node's motionPlan.slotValues. To set slots through raw inputOverrides, replace the node's whole motionPlan with a copy of the published plan whose slotValues carry your changes, with colors as RGBA arrays. A partial slotValues patch would drop the rest of the plan. The Nodaro app and the SDK build this for you.

{
  "inputOverrides": {
    "node-mg1": {
      "motionPlan": {
        // ...the published node's motionPlan, unchanged...
        "slotValues": { "primaryColor": [0, 1, 0, 1], "nameText": "Acme Inc." }
      }
    }
  }
}

Step 3: Build the form

// Adapt to your framework.
type FormField = {
  nodeId: string
  field: string
  label: string                     // "aspectRatio" becomes "Aspect ratio"
  control: "text" | "textarea" | "number" | "toggle" | "select" | "upload"
  options?: Array<string | number | boolean>
  defaultValue: unknown
}

async function buildForm(slug: string, baseUrl: string): Promise<FormField[]> {
  const app = await fetch(`${baseUrl}/v1/app/${slug}`).then((r) => r.json())
  const nodesById = new Map(app.snapshotNodes.map((n) => [n.id, n]))

  const fields: FormField[] = []
  const walk = (items) => {
    for (const it of items ?? []) {
      if (it.type === "group") walk(it.items)
      if (it.type !== "field") continue
      const node = nodesById.get(it.nodeId)
      const current = node?.data?.[it.field]
      fields.push(toFormField(it, node, current))
    }
  }
  walk(app.snapshotSettings?.presentationSettings?.inputItems ?? [])
  return fields
}
  • Key the values by node id, not by label. The run body is { "inputOverrides": { "<nodeId>": { "<field>": value } } }.
  • Nodaro enforces allowedValues. A value outside the list is refused with 400 validation_error.
  • Prefill the defaults from snapshotNodes[i].data, so a user who changes nothing still gets a meaningful run.
  • Show estimatedCredits, so users know the cost before they run.
  • Show the daily limit when maxRunsPerUserPerDay is set, such as "2 of 5 runs used today", to avoid a surprise 429.

Step 4: Start a run and poll it

Start the run with a token:

curl -X POST https://app.nodaro.ai/v1/app/<slug>/run \
  -H "Authorization: Bearer $NODARO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "inputOverrides": {
      "node-abc": { "prompt": "a cat astronaut", "aspectRatio": "16:9" }
    }
  }'

The answer is 202 Accepted:

{ "executionId": "exec-uuid", "runId": "run-uuid", "status": "pending" }
Optional body fieldWhat it does
versionRuns a given version of the app. The default is the latest.
inputsThe app's inputs as a flat map of input names to values, as the SDK and the CLI send them. inputOverrides is applied over inputs, field by field, and wins where both set the same field.
runIdAttaches the run to a draft run you created before. You can ignore it for a first build.

inputOverrides can also set node fields the app does not expose, such as promptPrefix; see Prompt pre and post text. It can never change where an outbound node sends or fetches, such as a Webhook Output address: that run is refused with 400 locked_field.

Then poll the run:

curl https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
  -H "Authorization: Bearer $NODARO_API_TOKEN"
{
  "id": "run-uuid",
  "executionId": "exec-uuid",
  "status": "pending|running|completed|failed",
  "creditsUsed": 0,
  "thumbnailUrl": null,                       // set when the run completes
  "execution": {
    "status": "pending|running|completed|failed",
    "nodeStates": {
      "node-abc": {
        "status": "completed",
        "output": {                           // the shape depends on the node's outputType
          "url": "https://.../result.png",
          "imageUrl": "...",
          "videoUrl": "...",
          "audioUrl": "...",
          "resultUrl": "...",
          "text": "..."
        }
      }
    },
    "totalNodes": 5,
    "completedNodes": 5,
    "failedNodes": 0,
    "totalCreditsUsed": 5,
    "errorMessage": null,
    "completedAt": "2026-05-07T..."
  }
}
  • Poll about every 2 seconds. Stop when execution.status is completed or failed.
  • Show progress as completedNodes out of totalNodes while the run is going.
  • On failure, show execution.errorMessage.

Step 5: Show the result

The main result is the output of the node thumbnailNodeId names. Read its URL from execution.nodeStates[thumbnailNodeId].output, trying these keys in order: url, imageUrl, videoUrl, audioUrl, resultUrl, then text for a text result, which you show as text rather than as a link.

Render it according to the node's outputType, from GET /v1/nodes/{type}:

outputTypeRender it as
imageAn image
videoA video player with controls
audioAn audio player with controls
textPreformatted text or Markdown
dataA JSON viewer, or opaque data

When thumbnailNodeId is null, use the last node in the workflow's order, or show every output that is not empty.

Delete a run

curl -X DELETE https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
  -H "Authorization: Bearer $NODARO_API_TOKEN"

A delete moves the run to the user's archive and answers { "success": true, "archived": true }. Nothing is destroyed: an automation that deletes the wrong run cannot lose the user's data. Restoring a run and deleting it for good are possible only in the Nodaro app, so your integration should not try either.

Authenticate

Every run needs a bearer token. Choose the kind by who should own and pay for the runs:

You are buildingUse
A personal tool, an agency dashboard or an internal automationA personal API token.
A SaaS where customers connect their own Nodaro accountsOAuth.
A public tool that runs an app for anonymous visitorsA personal API token. You pay, so limit the runs per visitor on your side.

Personal API token. Your account owns and pays for every run. Create the token in Settings › API with Create Token, copy it once, and store it as a server secret. It lasts until you revoke it. See Authentication.

OAuth. Each user's own account owns and pays for their runs. Register a developer app, send the user to the consent screen with the scopes workflows:execute, to start runs, and jobs:read, to poll them. Exchange the code on your server for an access token that lasts 90 days. If a call answers 403 insufficient_scope, send the user through consent again with the broader scopes.

Keep the token on your server

The token must never reach the browser. Bundlers inline every environment variable with a VITE_ or NEXT_PUBLIC_ prefix into the JavaScript they ship, where anyone with developer tools can read it and spend your credits.

[Browser]  --HTTPS-->  [Your server or edge function]  --HTTPS-->  Nodaro API
                         (holds NODARO_API_TOKEN)
StackWhere the token lives
Lovable with SupabaseA Supabase Edge Function secret: supabase secrets set NODARO_API_TOKEN=...
Next.jsA server route handler, with process.env.NODARO_API_TOKEN and no NEXT_PUBLIC_ prefix
SvelteKit, Remix, NuxtA server-only environment variable, such as $env/static/private in SvelteKit
Vercel or Netlify edge functionA project environment variable that is not exposed to the client
Cloudflare WorkerA Worker secret: wrangler secret put NODARO_API_TOKEN

Because your browser code talks only to your own server, you do not need to add your domain to Nodaro's allowed origins. If you do call Nodaro from the browser with OAuth, register your origin in the developer app's Allowed origins.

Errors

Errors have the shape { "error": { "code": "...", "message": "..." } }.

StatusCodeCauseWhat to do
400validation_errorA malformed inputOverrides, a value outside allowedValues, or a malformed slug.Fix the request body.
400locked_fieldAn override names a destination of an outbound node, such as a Webhook Output address.Remove it. The app decides where it sends and fetches.
401unauthorizedThe token is missing, expired or revoked.Create a new token, or authorize again.
402insufficient_app_creditsThe account behind the token has too few credits.Add credits or change the plan.
403insufficient_scopeThe OAuth token lacks a scope, named in missingScope.Authorize again with the broader scopes.
404not_foundThe slug or the run does not exist, or the app is deactivated.Check the slug, and show "App unavailable".
429rate_limit_exceededThe app's daily run limit per user is reached.Show "Daily limit reached".
500internal_errorA server fault.Retry once after a pause, and report it if it persists.

Reference templates

A Supabase Edge Function that proxies the app

// supabase/functions/nodaro-run/index.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts"

const BASE = Deno.env.get("NODARO_BASE_URL")!       // for example https://app.nodaro.ai
const TOKEN = Deno.env.get("NODARO_API_TOKEN")!     // ndr_...
const SLUG = Deno.env.get("NODARO_APP_SLUG")!       // my-cool-app

serve(async (req) => {
  const url = new URL(req.url)

  // Public probe: no token is sent, because the endpoint is public.
  if (req.method === "GET" && url.pathname.endsWith("/schema")) {
    const r = await fetch(`${BASE}/v1/app/${SLUG}`)
    return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
  }

  // Start a run.
  if (req.method === "POST" && url.pathname.endsWith("/run")) {
    const { inputs } = await req.json()
    const r = await fetch(`${BASE}/v1/app/${SLUG}/run`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ inputOverrides: inputs }),
    })
    return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
  }

  // Poll a run.
  const m = url.pathname.match(/\/runs\/([0-9a-f-]{36})$/)
  if (req.method === "GET" && m) {
    const r = await fetch(`${BASE}/v1/app/${SLUG}/runs/${m[1]}`, {
      headers: { "Authorization": `Bearer ${TOKEN}` },
    })
    return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
  }

  return new Response("Not found", { status: 404 })
})

Browser code: read, run, poll

// 1. On mount: read the schema and build the form (steps 1 to 3).
const schema = await fetch("/functions/v1/nodaro-run/schema").then((r) => r.json())
const inputItems = schema.snapshotSettings?.presentationSettings?.inputItems ?? []

// 2. On submit: send the values keyed by node id.
const start = await fetch("/functions/v1/nodaro-run/run", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    inputs: { "node-abc": { prompt: form.prompt, aspectRatio: form.aspectRatio } },
  }),
}).then((r) => r.json())

// 3. Poll every 2 seconds until the run ends.
let last = start
while (last.status !== "completed" && last.execution?.status !== "completed"
       && last.status !== "failed" && last.execution?.status !== "failed") {
  await new Promise((res) => setTimeout(res, 2000))
  last = await fetch(`/functions/v1/nodaro-run/runs/${start.runId}`).then((r) => r.json())
}

// 4. Read the result.
const out = last.execution?.nodeStates?.[schema.thumbnailNodeId]?.output ?? {}
const heroUrl = out.url ?? out.imageUrl ?? out.videoUrl ?? out.audioUrl ?? out.resultUrl
const heroText = out.text

In this template, the browser sends values keyed by node id, and the edge function passes them on as inputOverrides.

Checklist before you generate interface code

  • GET {BASE}/v1/app/{slug} returned the input schema and the defaults.
  • For each nodeId in inputItems, you know its type from snapshotNodes.
  • For each node type, GET {BASE}/v1/nodes/{type} told you its category, outputType and models.
  • You built the list of form fields with the rules of step 2.
  • You know the result node, thumbnailNodeId or the last node, and its outputType.
  • You have a personal API token or developer app credentials.
  • The secret lives on your server.

Frequently asked questions

Last updated on

On this page