Embed a Nodaro app in an external UI
Scope: This doc covers exactly one use case — building a custom frontend (web or mobile) that runs an already-published Nodaro app via the REST API. For other Nodaro integration paths (calling individual nodes, building workflows programmatically, MCP, OAuth for general multi-tenant access), see the links at the bottom of this doc.
Audience: AI code generators (Lovable, v0, Bolt, Cursor, Claude, ChatGPT). Hand this whole doc to the model — it is self-contained for the app-embedding use case.
You are building a web (or mobile) UI that runs an existing published Nodaro app via Nodaro’s REST API. The Nodaro app is already built and deployed; your job is to collect inputs, kick off a run, poll for the result, and display the output.
This doc is structured so an LLM can follow it linearly:
- Three things to know up front
- Step 1 — Probe the app to discover its inputs
- Step 2 — Probe each node type for field schemas
- Step 3 — Map the discovered schema to a form
- Step 4 — Run the app and poll for results
- Step 5 — Extract and display the output
- Authentication options
- The server-side secret rule (CRITICAL)
- Error catalog
- Reference templates
1. Three things to know up front
| Base URL | https://app.nodaro.ai (managed) or your own self-hosted URL |
| App identifier | A slug — the last path segment of the published app URL (e.g. https://app.nodaro.ai/app/my-cool-app → slug is my-cool-app) |
| Run shape | Async — POST /v1/app/:slug/run returns immediately with a runId. You must poll GET /v1/app/:slug/runs/:runId for the result. There are no webhooks. |
There are exactly two probe endpoints you need before writing any UI code:
GET /v1/app/:slug— public, no auth. Returns the app’s input schema and metadata.GET /v1/nodes/:type— public, no auth. Returns the field schema for one node type.
Once you’ve probed both, you have everything needed to render a typed form.
2. Step 1 — Probe the app to discover its inputs
Call this first, no auth required:
curl https://app.nodaro.ai/v1/app/<slug>
The response is JSON. The fields you care about (others omitted for brevity):
{
"id": "uuid",
"name": "Headline Generator",
"description": "...",
"iconUrl": "https://...",
"version": 3, // latest version number
"estimatedCredits": 5, // credits one run costs
"maxRunsPerUserPerDay": null, // or a number
"thumbnailNodeId": "node-abc", // node whose output is the "hero" result
"snapshotNodes": [ // the workflow's nodes
{
"id": "node-abc",
"type": "generate-image", // <-- use this in step 2
"data": {
"prompt": "default prompt", // current/default value for each field
"model": "flux",
"aspectRatio": "1:1"
}
}
// ...more nodes...
],
"snapshotEdges": [ /* DAG wiring, usually irrelevant to your UI */ ],
"snapshotSettings": {
"presentationSettings": {
"inputItems": [ // <-- THIS is the form schema
{
"type": "field",
"id": "item-1",
"nodeId": "node-abc",
"field": "prompt",
"allowedValues": null // or ["a","b","c"] if the field is restricted
},
{
"type": "field",
"id": "item-2",
"nodeId": "node-abc",
"field": "aspectRatio",
"allowedValues": ["1:1", "16:9", "9:16"]
}
// type can also be "node" | "output" | "richtext" | "group"
]
}
},
"versions": [{ "version": 3, "id": "...", "createdAt": "..." }]
}
What inputItems items mean
type |
Render as |
|---|---|
field |
A form input. Read nodeId, field, optional allowedValues. |
node |
The whole node’s default UI block — you can usually skip this when building a custom form, OR treat as “render every field of this node”. |
output |
A live output preview (ignore at form-build time; show after run). |
richtext |
Static markdown the publisher wrote — render verbatim. |
group |
Container with items: PresentationItem[]. Recurse. No nested groups. |
Use the helper flattenItems() mental model: walk inputItems, recurse into group, collect every field item — that’s your form.
3. Step 2 — Probe each node type for field schemas
The inputItems from step 1 give you (nodeId, field) pairs but no type info (text vs slider vs select). To learn the field type, look up the node:
curl https://app.nodaro.ai/v1/nodes/generate-image
Response:
{
"data": {
"type": "generate-image",
"label": "Generate Image",
"category": "ai-image",
"description": "Generate an image from a text prompt.",
"outputType": "image",
"creditCost": "1-8",
"providers": ["flux", "nano-banana", "ideogram", "z-image", "grok", "gpt-image", "nano-banana-pro"],
"capabilities": ["supports-reference-image", "supports-negative-prompt"]
}
}
Inferring field type when inputSchema is missing
Not every node descriptor has a full inputSchema. When it doesn’t, infer from these rules in order:
allowedValuespresent in the inputItem → render as<select>with those options.- Current value in
snapshotNodes[i].data[field]is aboolean→ toggle/checkbox. - Current value is a
number→ number input (or slider if the field name matches*duration,*intensity,*strength,*scale,*temperature). - Current value is a
stringand field name matchesprompt|description|text|content|caption|message→ multiline<textarea>. - Current value is a
stringmatching a URL pattern AND node type starts withupload-→ file upload widget; submit the public URL of the upload. - Anything else → single-line
<input type="text">.
For known field-type hints, see this table (covers ~90% of exposed fields):
| Field name pattern | Type | Notes |
|---|---|---|
prompt, negativePrompt, text, description |
textarea | |
model, provider, voice, style, tone |
select | options come from allowedValues or providers array |
aspectRatio |
select | usually ["1:1","16:9","9:16","4:3","3:4"] |
resolution |
select | usually ["1K","2K","4K"] or ["720p","1080p","4k"] |
quality |
select | usually ["medium","high"] or similar |
duration, nFrames, seed, temperature |
number | |
enableTranslation, addAudio, headless, loop |
toggle | |
imageUrl, videoUrl, audioUrl, referenceImage |
URL string | upload elsewhere first; paste resulting URL |
Lottie slot fields (slot:<sid>)
A Motion Graphics node running the Lottie engine can expose its named slots
(colors, text, numbers) as app inputs. These show up in inputItems as field
items whose field key is prefixed slot: — e.g. slot:primaryColor,
slot:nameText. End-users edit them per run, with zero credits (no
regeneration — only the existing animation’s slot values change).
Freeze-on-exposure: when an app exposes slot fields, that animation’s plan is frozen — every run reuses the published animation and only swaps slot values (no regeneration cost). An app that does not expose any slot fields regenerates the animation each run as usual.
The field type comes from the slot’s value, not a name pattern:
| Slot value kind | Control | Submit value |
|---|---|---|
RGBA color array [r,g,b,a] (0–1) |
color picker | hex string, e.g. "#00ff00" |
| string | text input | the string |
| number | slider | a number |
Override mechanic (important for raw inputOverrides): a slot value is NOT a
literal node-data field — it lives inside the node’s motionPlan.slotValues. To
override it you replace the whole motionPlan for that node with a copy of
the snapshot plan whose slotValues carry your edits (colors as 0–1 RGBA
arrays):
{
"inputOverrides": {
"node-mg1": {
"motionPlan": {
// ...spread the snapshot node's motionPlan unchanged...
"slotValues": { "primaryColor": [0, 1, 0, 1], "nameText": "Acme Inc." }
}
}
}
}
The Nodaro web app and SDK compose this for you automatically — you only need
the manual form when building a fully custom integration. (The orchestrator
merge is shallow, so a partial slotValues patch would drop the rest of the
plan; always send the full motionPlan.)
4. Step 3 — Map the discovered schema to a form
Concrete algorithm to produce a form definition:
// Pseudocode — adapt to your framework.
type FormField = {
nodeId: string
field: string
label: string // humanize: "aspectRatio" → "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 points your generated UI must respect:
- Form keys are
nodeId, not field labels. When the user submits, the body shape is{ inputOverrides: { [nodeId]: { [field]: value } } }. allowedValuesis enforced server-side. Submitting a value not in the list returns 400validation_error.- Pre-fill defaults from
snapshotNodes[i].data. A user who submits without changing anything still gets a meaningful run. - Show
estimatedCreditssomewhere visible — users like knowing the cost. - If
maxRunsPerUserPerDayis set, surface it (e.g. “You’ve used 2 of 5 today”) to avoid surprise 429s.
5. Step 4 — Run the app and poll for results
Start the run (auth required)
curl -X POST https://app.nodaro.ai/v1/app/<slug>/run \
-H "Authorization: Bearer ndr_<your-token>" \
-H "Content-Type: application/json" \
-d '{
"inputOverrides": {
"node-abc": { "prompt": "a cat astronaut", "aspectRatio": "16:9" }
}
}'
Response (202 Accepted):
{
"executionId": "exec-uuid",
"runId": "run-uuid",
"status": "pending"
}
Optional body fields:
version: number— pin to a specific version (default: latest)runId: uuid— pre-created draft run (advanced; ignore for first build)
Poll until done
curl https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
-H "Authorization: Bearer ndr_<your-token>"
Response shape:
{
"id": "run-uuid",
"executionId": "exec-uuid",
"status": "pending|running|completed|failed",
"creditsUsed": 0,
"thumbnailUrl": null, // populated when complete
"execution": {
"status": "pending|running|completed|failed",
"nodeStates": {
"node-abc": {
"status": "completed",
"output": { // shape depends on node outputType
"url": "https://r2.../result.png", // try these keys, in order:
"imageUrl": "...", // url, imageUrl, videoUrl,
"videoUrl": "...", // audioUrl, resultUrl, text
"audioUrl": "...",
"resultUrl": "...",
"text": "..."
}
}
},
"totalNodes": 5,
"completedNodes": 5,
"failedNodes": 0,
"totalCreditsUsed": 5,
"errorMessage": null,
"completedAt": "2026-05-07T..."
}
}
Polling cadence: every 2 seconds is fine. Stop when execution.status is completed or failed. Show a progress bar from completedNodes / totalNodes while running.
Show errors from execution.errorMessage on failure.
6. Step 5 — Extract and display the output
The “hero” output is whatever thumbnailNodeId from step 1 points at. Extract its URL by trying these keys in order on execution.nodeStates[thumbnailNodeId].output:
urlimageUrlvideoUrlaudioUrlresultUrltext(for text outputs — render directly, not as a URL)
Render based on the node’s outputType from GET /v1/nodes/:type:
outputType |
Render as |
|---|---|
image |
<img src={url} /> |
video |
<video src={url} controls /> |
audio |
<audio src={url} controls /> |
text |
<pre>{text}</pre> or markdown |
data |
JSON viewer / treat as opaque |
If thumbnailNodeId is null, fall back to the last node in topological order (or just iterate nodeStates and show every non-empty output).
6.5. Deleting a run (soft-delete / archive semantics)
curl -X DELETE https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
-H "Authorization: Bearer ndr_<token>"
This is a soft-delete: the run is moved to the user’s archive (hidden from the default run list, but recoverable). The response is { "success": true, "archived": true }.
Why soft, not hard:
- API / SDK / MCP
DELETEcalls cannot accidentally destroy a user’s data. If an automation deletes the wrong run, the user can still recover it. - Restore (
POST /v1/app/:slug/runs/:runId/restore) and permanent deletion (DELETE /v1/app/:slug/runs/:runId/permanent) are intentionally only exposed through the Nodaro UI at https://app.nodaro.ai/archived-runs. They are reachable via direct HTTPS for the UI’s needs but are not advertised as integration endpoints — your integrations should not call them.
If you need confirmation flow in your UI, ask the user to delete in your app, then call DELETE — they can always recover from Nodaro’s archive if needed.
7. Authentication options
There are two ways to get a Bearer token. Pick one based on your use case.
Option A — Personal API token (simplest)
Use when you (one Nodaro account) own all runs. Your account pays the credits. Best for a personal tool, internal dashboard, or single-tenant SaaS.
- Sign in to Nodaro.
- Go to
/settings/api-tokens→ New API token. - Copy the
ndr_<64hex>value (shown once). - Store it as a server-side secret (see §8).
- Send as
Authorization: Bearer ndr_...on every call.
Tokens don’t expire until revoked. Revoke from the same settings page.
Option B — OAuth 2.0 (multi-user)
Use when your app’s end-users have their own Nodaro accounts and you want each user’s runs charged to their account.
This is a standard authorization-code flow. Full walkthrough: oauth-flow.md. Summary:
- Register a developer app at
/settings/developer-apps. GetclientIdandclientSecret(clientSecret shown once). - Redirect the user to
/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&scope=workflows:execute+jobs:read&state=<csrf>. - User clicks Allow → browser redirected to your
redirect_uriwith?code=ndr_code_<48hex>&state=<echo>. - Exchange the code (server-side) at
POST /v1/oauth/tokenwithclient_id+client_secret+code. Receiveaccess_token(ndr_app_<64hex>, lifetime 90 days). - Use
Authorization: Bearer ndr_app_...on every call. The token carries the user’s identity and granted scopes.
Required scopes for running a published app: workflows:execute (run) and jobs:read (poll).
If a 403 insufficient_scope comes back, re-do the authorize flow with broader scopes.
Which to choose
| You | Use |
|---|---|
| Building a personal tool, agency dashboard, or internal automation | Personal API token |
| Building a SaaS where customers connect their own Nodaro account | OAuth |
| Building a public marketplace tool that runs apps for anonymous visitors | Personal API token (you pay; rate-limit per visitor on your side) |
8. The server-side secret rule (CRITICAL)
The Bearer token must never appear in the browser bundle.
Vite, Next.js, and other bundlers inline any env var prefixed VITE_* / NEXT_PUBLIC_* into the shipped JS — anyone with devtools can read it and burn your credits.
The required architecture:
[Browser UI] ──HTTPS──> [Your server / Edge Function] ──HTTPS──> Nodaro API
(holds NODARO_API_TOKEN)
Concrete recipes:
| Stack | Where the token lives |
|---|---|
| Lovable + Supabase | Supabase Edge Function secret (supabase secrets set NODARO_API_TOKEN=...) |
| Next.js | Server route handler / API route (process.env.NODARO_API_TOKEN, without NEXT_PUBLIC_ prefix) |
| SvelteKit / Remix / Nuxt | Server-only env ($env/static/private in SvelteKit) |
| Vercel / Netlify Edge Function | Project env var (NOT exposed to client) |
| Cloudflare Worker | Worker secret (wrangler secret put NODARO_API_TOKEN) |
Browser CORS: since the browser only ever talks to your own server (not Nodaro), you don’t need to add your domain to Nodaro’s allowed origins. (If you DO call Nodaro from the browser via OAuth, register your origin in the developer app’s allowed_origins.)
9. Error catalog
All errors have shape { "error": { "code": "...", "message": "..." } }.
| HTTP | Code | Cause | Action |
|---|---|---|---|
| 400 | validation_error |
Bad inputOverrides shape, value not in allowedValues, malformed slug |
Fix the request body |
| 401 | unauthorized |
Missing/expired/revoked token | Re-mint or re-authorize |
| 402 | insufficient_app_credits |
Token’s account is out of credits | Top up credits or switch plan |
| 403 | insufficient_scope (with missingScope field) |
OAuth token doesn’t have required scope | Re-do /oauth/authorize with broader scopes |
| 404 | not_found |
Slug or runId doesn’t exist (or app deactivated) | Check the slug; surface “App unavailable” |
| 429 | rate_limit_exceeded |
Daily run cap (maxRunsPerUserPerDay) reached |
Show “Daily limit reached” UI |
| 500 | internal_error |
Server fault | Retry once with backoff; alert if persistent |
10. Reference templates
Supabase Edge Function (Deno) — proxy + start run + poll
// 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")! // e.g. 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 — proxy unauthenticated. No token sent (the upstream 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 runId = m[1]
const r = await fetch(`${BASE}/v1/app/${SLUG}/runs/${runId}`, {
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-side: discover, run, poll (vanilla TS, no SDK needed)
// 1) On mount: probe schema and build a form.
const schema = await fetch("/functions/v1/nodaro-run/schema").then(r => r.json())
const inputItems = schema.snapshotSettings?.presentationSettings?.inputItems ?? []
// ...render form using rules from §3 + §4...
// 2) On submit: POST inputOverrides keyed by nodeId.
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 2s until terminal.
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())
// updateProgress(last.execution.completedNodes, last.execution.totalNodes)
}
// 4) Extract output.
const tnId = schema.thumbnailNodeId
const out = last.execution?.nodeStates?.[tnId]?.output ?? {}
const heroUrl = out.url ?? out.imageUrl ?? out.videoUrl ?? out.audioUrl ?? out.resultUrl
const heroText = out.text
Probe checklist for the LLM (do all of these before generating UI code)
GET {BASE}/v1/app/{slug}— got the input schema and defaults- For each unique
nodeIdreferenced ininputItems, looked upsnapshotNodes[i].type - For each unique node type, called
GET {BASE}/v1/nodes/{type}to learn category, outputType, providers - Built a
FormField[]array using the inference rules in §3 - Identified the output node (
thumbnailNodeIdor last-in-topological-order) and itsoutputType - Confirmed the user has provided either
NODARO_API_TOKEN(option A) or developer-app credentials (option B) - Confirmed the secret will live server-side per §8
See also
- API Integration — the underlying REST API (per-endpoint access, not full apps)
- OAuth Flow — full multi-user flow, including consent UI and revocation
- SDK Quickstart —
@nodaro/sdktyped SDK (covers projects/workflows/jobs/executions; published-apps endpoints are not yet wrapped — call them viafetchas shown above) - Node Reference — every supported node type