# 미니앱 임베드

> 게시된 Nodaro 미니앱을 임베드 코드나 REST API 위에 만든 나만의 양식으로 내 제품에서 실행하세요. 입력 읽기부터 실행, 결과, 오류까지 설명합니다.

Source: https://nodaro.ai/ko/docs/developers/embed/miniapps

**미니앱 임베드**란 이미 게시된 Nodaro 앱을 내 제품에서 실행하는 것입니다. 앱의 임베드 코드를 페이지에 붙여 넣을 수도 있고, REST API 위에 나만의 양식을 만들 수도 있습니다. 앱의 입력을 읽고, 실행을 시작하고, 폴링하고, 결과를 보여 주면 됩니다. 앱 자체는 Nodaro에 남고, 내 제품은 입력을 받고 출력을 표시합니다.

## 두 가지 임베드 방법
| | 임베드 코드 | 나만의 인터페이스 |
| --- | --- | --- |
| **작업량** | `iframe` 태그 하나를 붙여 넣습니다. | 양식과 작은 서버 라우트를 만듭니다. |
| **모양** | Nodaro 앱 화면입니다. | 나만의 디자인입니다. |
| **실행 주체** | 보는 사람이 자신의 Nodaro 세션으로 실행합니다. | 내 토큰에 연결된 계정입니다. |

## 임베드 코드 붙여 넣기
### 앱의 임베드 설정 열기
Nodaro에서 **미니앱**을 열고 **내 미니앱**을 선택한 다음, 앱 카드에서 **임베드**를 클릭하세요.

### 내 도메인 허용
**임베드 허용 도메인**에 `https://example.com` 같은 내 사이트의 출처를 입력하고 **추가**를 클릭하세요. 도메인을 하나 이상 추가하기 전까지는 임베드가 차단됩니다.

### 코드를 페이지에 복사
**임베드 코드 복사**를 클릭하고 태그를 페이지에 붙여 넣으세요.

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

## 나만의 인터페이스 만들기
이 페이지의 나머지 부분에서는 REST API를 사용해 게시된 앱의 맞춤 웹 또는 모바일 인터페이스를 만듭니다. 내용이 그 자체로 완결되어 있으므로 AI 코드 생성기에 그대로 넘겨도 됩니다. 이 페이지의 URL 끝에 `.md`를 붙이면 Markdown 사본을 받을 수 있습니다.

### 먼저 알아야 할 세 가지
| | |
| --- | --- |
| **기본 URL** | Nodaro Cloud에서는 `https://app.nodaro.ai`, 셀프 호스팅 설치에서는 그 설치의 주소입니다. |
| **앱 식별자** | `slug`, 즉 게시된 앱 URL의 마지막 부분입니다. `https://app.nodaro.ai/app/my-cool-app`의 slug는 `my-cool-app`입니다. |
| **실행 방식** | 비동기입니다. `POST /v1/app/{slug}/run`은 즉시 `runId`로 응답하며, 결과는 `GET /v1/app/{slug}/runs/{runId}`를 폴링해 받습니다. 실행은 콜백을 보내지 않습니다. |

토큰이 필요 없는 공개 엔드포인트 두 개가 양식에 필요한 모든 정보를 알려 줍니다.

- `GET /v1/app/{slug}`는 앱의 입력과 메타데이터를 반환합니다.
- `GET /v1/nodes/{type}`은 노드 타입 하나의 필드를 반환합니다.

### 1단계: 앱의 입력 읽기
```bash
curl https://app.nodaro.ai/v1/app/<slug>
```

인터페이스에 중요한 필드는 다음과 같습니다.

```jsonc
{
"id": "uuid",
"name": "Headline Generator",
"description": "...",
"iconUrl": "https://...",
"version": 3,                        // the latest version
"estimatedCredits": 5,               // the listed price: a run, with any Render final after it
"perMinuteCredits": 0,               // per minute of the recording the app is given; 0 when the price does not depend on it
"perItemCredits": 0,                 // per item beyond the saved items of a list the user fills; 0 when none
"maxEstimatedCredits": 9,            // "up to": the most a run can cost over the user's choices (with
"maxPerMinuteCredits": 0,            // maxPerMinuteCredits and maxPerItemCredits); present only when it
"maxPerItemCredits": 0,              // differs from the "from" figures above
"runEstimatedCredits": 5,            // the run alone: the listed price without its Render final part
"runPerMinuteCredits": 0,            // ...and its per-minute and per-item parts
"runPerItemCredits": 0,
"editPlanPerMinute": false,          // true when an Edit Plan node is charged per started minute of the recording
"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, "maxLength": 1200 },
{ "type": "field", "id": "item-2", "nodeId": "node-abc", "field": "aspectRatio", "allowedValues": ["1:1", "16:9", "9:16"] }
]
}
},
"versions": [{ "version": 3, "id": "...", "createdAt": "..." }]
}
```

`inputItems`는 게시자가 설계한 양식입니다. 이를 순회하면서 모든 `group` 안으로 들어가 모든 `field` 항목을 모으세요. 그것이 내 양식입니다.

| `type` | 렌더링 방법 |
| --- | --- |
| `field` | 양식 입력입니다. `nodeId`, `field`, 그리고 선택 항목인 `allowedValues`와 `maxLength`(텍스트 입력의 글자 수 제한)를 읽으세요. |
| `node` | 노드 전체의 기본 블록입니다. 맞춤 양식에서는 건너뛰거나, 노드의 모든 필드를 렌더링하세요. 노드 전체가 노출된 **텍스트** 노드에도 `maxLength`가 있을 수 있습니다. |
| `output` | 결과의 실시간 미리보기입니다. 양식을 만드는 동안에는 무시하고, 실행 후에 보여 주세요. |
| `richtext` | 게시자가 작성한 정적 Markdown입니다. 그대로 렌더링하세요. |
| `group` | 자체 `items`를 가진 컨테이너입니다. 안으로 들어가세요. 그룹은 중첩되지 않습니다. |

### 2단계: 각 필드의 타입 알아내기
`inputItems`는 `nodeId`와 `field`의 쌍을 알려 주지만, 텍스트, 숫자, 선택지 같은 필드 타입은 알려 주지 않습니다. 더 알아보려면 노드 타입을 조회하세요.

```bash
curl https://app.nodaro.ai/v1/nodes/generate-image
```

```jsonc
{
"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"]
}
}
```

모든 노드 디스크립터에 완전한 `inputSchema`가 있는 것은 아닙니다. 없을 때는 다음 규칙을 순서대로 적용해 컨트롤을 추론하세요.

1. 입력 항목에 `allowedValues`가 있으면, 그 옵션들로 된 선택 상자입니다.
2. `snapshotNodes[i].data[field]`의 현재 값이 불리언이면, 토글입니다.
3. 현재 값이 숫자이면, 숫자 입력입니다. 필드 이름이 `duration`, `intensity`, `strength`, `scale`, `temperature`로 끝나면 슬라이더입니다.
4. 현재 값이 문자열이고 필드 이름이 `prompt`, `description`, `text`, `content`, `caption`, `message`이면, 여러 줄 텍스트 영역입니다.
5. 현재 값이 URL이고 노드 타입이 `upload-`로 시작하면, 파일의 공개 URL을 제출하는 파일 업로드입니다.
6. 그 밖의 경우는 한 줄 텍스트 입력입니다.

노출되는 필드는 대부분 다음 필드 이름에 해당합니다.

| 필드 이름 | 컨트롤 | 참고 |
| --- | --- | --- |
| `prompt`, `negativePrompt`, `text`, `description` | 텍스트 영역 | |
| `model`, `provider`, `voice`, `style`, `tone` | 선택 상자 | 옵션은 `allowedValues` 또는 `providers`에서 가져옵니다. |
| `aspectRatio` | 선택 상자 | 보통 `1:1`, `16:9`, `9:16`, `4:3`, `3:4`입니다. |
| `resolution` | 선택 상자 | 보통 `1K`, `2K`, `4K`, 또는 `720p`, `1080p`, `4k`입니다. |
| `quality` | 선택 상자 | 보통 `medium`과 `high`입니다. |
| `duration`, `nFrames`, `seed`, `temperature` | 숫자 | |
| `enableTranslation`, `addAudio`, `headless`, `loop` | 토글 | |
| `imageUrl`, `videoUrl`, `audioUrl`, `referenceImage` | URL | 먼저 파일을 업로드한 다음 그 URL을 제출하세요. |

MCP에서는 `get_app_inputs`가 이 중 일부의 타입을 알려 줍니다. 노출된 슬라이더는 자체 `min`, `max`, `step`이 있는 `type: "number"`로 돌아옵니다. 게시자가 값 목록으로 제한한 필드는 그 `options`가 있는 `type: "select"`로 돌아옵니다. 노드의 빠른 설정에 있는 필드는 `select`, `number`, `boolean` 중 하나로 돌아옵니다. [모델 및 빠른 설정 필드](#quick-settings)를 참고하세요. 그 밖의 필드는 `type: "text"`로 돌아오므로, 노드를 조회하세요. 슬라이더의 플랫 입력에는 숫자나 숫자 문자열을 넣을 수 있습니다.

**슬라이더와 토글.** 슬라이더나 토글은 `snapshotNodes[i].data[field]`에 있는 노드의 저장된 값에서 시작합니다. 노드에 저장된 값이 없으면 앱은 필드의 기본값, 곧 새 노드가 받는 값을 보여 줍니다. 실행은 사용자가 값을 바꾼 뒤에만 필드를 보냅니다. 그 전에는 노드 자체의 값이 쓰입니다. 내 인터페이스에서도 똑같이 하세요. 컨트롤을 미리 채우고, 사용자가 바꿀 때만 필드를 보내세요. **텍스트 음성 변환**의 보이스 설정은 숫자나 숫자 문자열을 받으며, 범위를 벗어난 값은 범위 안으로 맞춰집니다.

#### 모델 및 빠른 설정 필드
편집기에서 노드 하단 바에 있는 모든 설정은 앱에 노출할 수 있습니다. 모델, 그리고 대부분의 생성 노드에서는 해상도, 길이, 화면 비율, **초안** 같은 설정입니다. 예를 들어 [동영상으로 동영상 만들기](https://nodaro.ai/docs/nodes/video/video-to-video)는 **모델**, **해상도**, **초안**을 노출할 수 있고, [동영상 생성](https://nodaro.ai/docs/nodes/video/generate-video)은 **모델** 외에 **길이**, **해상도**, **화면 비율**, **초안**을 노출할 수 있습니다. 실행 횟수 설정인 **실행 횟수**는 제공되지 않습니다.

- **키는 노드 자체의 데이터 필드입니다.** `provider`, `v2vResolution`, `duration`, `draft` 같은 필드입니다. 값은 `inputOverrides[nodeId][field]`에 노드가 저장하는 타입으로 보내세요. 길이는 `"8"`이 아니라 `8`이고, 초안은 `true`입니다.
- **정해진 목록은 강제됩니다.** 노드의 다른 설정과 상관없이 같은 값을 제공하는 설정이 있습니다. 노드가 제공하는 모델, Suno 모델, 3D 장면의 화면 비율 같은 설정입니다. 목록에 없는 값은 `400 validation_error`(`Invalid value for provider: …. Allowed: …`)로 거부됩니다. 게시자는 `allowedValues`로 이 목록을 더 좁힐 수 있습니다.
- **범위는 강제됩니다.** 3D 장면의 **길이**나 동영상 마스크의 **임계값**처럼 범위 안의 숫자를 받는 설정은, 범위 밖의 숫자를 `400 validation_error`로 거부합니다.
- **모델에 따라 달라지는 선택지가 있습니다.** 설정이 제공하는 해상도, 화면 비율, 길이는 노드의 모델에 따라 다릅니다. 앱의 카드는 실행에 쓰일 모델의 선택지를 제공하며, 모델이 바뀌면 새 모델이 받지 않는 선택을 다른 값으로 옮깁니다. 내 양식에서도 똑같이 하세요. MCP `get_app_inputs`는 이런 필드에 대해 모든 모델의 값을 나열하며, `snapshotNodes[i].data.provider` 또는 내 양식이 보내는 값이 어떤 모델이 실행되는지 알려 줍니다.
- **초안은 480p로 렌더링됩니다.** Seedance 모델에서는 초안을 켜면 앱의 카드가 해상도도 480p로 설정하며, 서버는 어떤 해상도를 보내든 초안을 480p로 렌더링합니다.

#### 필드 비우기
`inputOverrides`에서 `null`로 설정한 필드는 그 실행에서 비워집니다. 노드는 그 자리에 값이 없는 것처럼 실행되며, `null`도 제작자가 저장한 값도 쓰지 않으므로 노드 자체의 기본값이 적용됩니다. 앱의 카드는 선택한 모델에 없는 설정에 대해 이렇게 합니다. 예를 들어 동영상으로 동영상 만들기를 해상도 설정이 없는 Runway Aleph로 바꾸면 해상도가 비워집니다. 카드는 초안을 끌 때도 이 방식을 씁니다. 비운 필드는 `allowedValues`나 정해진 목록에 의해 거부되지 않습니다.

MCP나 SDK의 플랫 입력에서는 `null`이 노드에 닿기 전에 버려지므로, 노드의 저장된 값이 적용됩니다. 필드를 비우려면 `inputOverrides`를 사용하세요.

#### Lottie 슬롯 필드
Lottie 엔진을 쓰는 [**모션 그래픽**](https://nodaro.ai/docs/nodes/video/motion-graphics) 노드는 색상, 텍스트, 숫자 같은 이름 있는 슬롯을 앱 입력으로 노출할 수 있습니다. 이 슬롯은 `slot:primaryColor`처럼 `field`가 `slot:`으로 시작하는 필드 항목으로 나타납니다. 사용자는 실행할 때마다 **0크레딧**으로 이를 바꿀 수 있습니다. 앱이 슬롯 필드를 노출하면, 모든 실행이 게시된 애니메이션을 다시 사용하고 슬롯 값만 바꾸기 때문입니다.

| 슬롯 값 | 컨트롤 | 제출할 값 |
| --- | --- | --- |
| 0~1 범위 숫자로 된 RGBA 배열 형태의 색상 | 색상 선택기 | `"#00ff00"` 같은 16진수 문자열 |
| 문자열 | 텍스트 입력 | 그 문자열 |
| 숫자 | 슬라이더 | 그 숫자 |

슬롯 값은 일반 노드 필드가 아니며, 노드의 `motionPlan.slotValues` 안에 있습니다. 원시 `inputOverrides`로 슬롯을 설정하려면, 노드의 `motionPlan` 전체를 게시된 플랜의 사본으로 바꾸고 그 `slotValues`에 변경 사항을 넣으세요. 색상은 RGBA 배열로 지정합니다. `slotValues`만 부분적으로 패치하면 플랜의 나머지가 사라집니다. Nodaro 앱과 SDK는 이를 자동으로 만들어 줍니다.

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

#### 동영상 URL 입력
[동영상 URL](https://nodaro.ai/docs/nodes/automate/video-url) 노드는 앱 입력이 될 수 있습니다. 그래서 앱은 에피소드를 업로드 대신 링크로 받을 수 있습니다. `get_app_inputs`는 이 필드를 `type: "video"`로 나열하며, 플랫 입력은 링크 그 자체입니다.

```json
{ "inputs": { "episode": "https://www.youtube.com/watch?v=VIDEO_ID" } }
```

- **받는 값.** YouTube, TikTok, Instagram, Facebook, X의 동영상 링크, 또는 동영상을 가리키는 공개 `http`나 `https` 링크입니다. 게시물 링크는 다운로드되고, 그 밖의 링크는 그대로 쓰입니다. 웹 링크가 아닌 값이나 사설 주소를 가리키는 값은 무엇이든 실행되거나 청구되기 전에 `400 locked_field`로 거부됩니다. 앱이 노출하지 않은 **동영상 URL** 노드에 대한 링크도 같은 방식으로 거부됩니다.
- **게시물은 서버가 다운로드합니다.** 첫 노드가 실행되기 전에, 서버는 링크 뒤의 노드가 읽는 것을 가져옵니다. 동영상을 보는 노드에는 동영상을 가져옵니다. **음성 텍스트 변환**과 **Suno 커버**에는 소리만 가져옵니다. **더빙**과 **콘텐츠 레시피**는 페이지 주소를 쓰므로 아무것도 가져오지 않습니다.
- **긴 YouTube 동영상에는 구간이 필요합니다.** 4분 이상인 YouTube 동영상이나 길이를 읽을 수 없는 동영상은 일부 구간만 다운로드됩니다. 그 구간을 `inputOverrides`의 노드에 초 단위로 지정하세요: `{ "<nodeId>": { "sectionStartSec": 600, "sectionEndSec": 1200 } }`. 구간이 없으면 실행은 청구되기 전에 거부됩니다. 라이브 스트림도 거부되며, 다운로드에 실패하면 그 이유와 함께 실행이 거부됩니다.
- **원시 오버라이드.** `inputOverrides`에서는 링크를 노드의 `youtubeUrl` 필드에 넣습니다.
- **가격.** 링크 뒤에서 동영상 길이로 청구되는 단계는 그 녹음 1분당 가격으로 `perMinuteCredits`에 포함됩니다.

#### UGC 동영상 입력
Nodaro Cloud에서 UGC 동영상 노드로 만든 앱에는 고유한 입력이 두 개 있습니다. 크리에이터 카드와 동영상 길이입니다.

**크리에이터 카드.** [UGC 크리에이터](https://nodaro.ai/docs/nodes/video/ugc-creator)의 `node` 항목이 크리에이터 카드입니다. **AI 크리에이터** 또는 **내 사진 사용**, **여성** 또는 **남성**을 고르고, 내 사진 사용이면 실행하는 사람의 사진을 업로드합니다. 카드는 노드의 필드 세 개에 값을 쓰므로, 실행은 이 값을 크리에이터의 노드 ID로 보냅니다.

```json
{
"inputOverrides": {
"<creator node id>": {
"source": "photo",
"gender": "woman",
"photoUrl": "https://example.com/runner.jpg"
}
}
}
```

- `source`는 AI 크리에이터일 때 `sampled`, 아니면 `photo`입니다.
- `gender`는 `woman` 또는 `man`입니다.
- `photoUrl`은 공개 이미지 URL이며, 내 사진 사용에서만 씁니다.

MCP `get_app_inputs`는 이 카드를 키 세 개로 나열합니다. `source`에는 크리에이터 자신의 키를, 그리고 같은 키에 `_gender`와 `_photo_url`을 붙인 키를 쓰며, 이 둘은 선택 사항입니다. 앱 실행은 크리에이터가 선택되기를 기다리지 않고 하나를 뽑습니다. 내 사진 사용 실행에 실행하는 사람 본인의 사진이 없으면, 무엇이든 실행되거나 청구되기 전에 `400 ugc_photo_required`로 거부됩니다.

**동영상 길이.** [UGC 스크립트](https://nodaro.ai/docs/nodes/video/ugc-script)의 **동영상 길이(초)**, 즉 `targetDurationSec`는 `field` 항목이 될 수 있습니다. 4~300초 슬라이더이거나, `[15, 20, 25]`처럼 앱이 `allowedValues`로 허용한 길이입니다. `inputOverrides`에서는 숫자로 보내세요. `"20"`이 아니라 `20`입니다. 플랫 입력은 둘 다 받습니다.

**UGC 노드의 다른 값은 실행이 설정할 수 없습니다.** 유지된 결과, 스크립트, 클립처럼 UGC 노드의 다른 필드는 `400 locked_field`로 거부됩니다. [앱의 UGC 동영상](https://nodaro.ai/docs/concepts/apps#a-ugc-video-in-an-app)을 참고하세요.

### 3단계: 양식 만들기
```ts
// 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
}
```

- **값의 키는 라벨이 아니라 노드 ID로 지정하세요.** 실행 본문은 `{ "inputOverrides": { "<nodeId>": { "<field>": value } } }`입니다.
- **Nodaro는 `allowedValues`를 강제합니다.** 목록에 없는 값은 `400 validation_error`로 거부됩니다. [빠른 설정 필드](#quick-settings)의 정해진 목록에 없는 값도 마찬가지입니다.
- **Nodaro는 `maxLength`를 강제합니다.** 제한보다 긴 텍스트는 `400 input_too_long`으로 거부됩니다. 텍스트 영역에 `maxlength`를 넣고 글자 수 카운터를 보여 주세요. 음성 입력에서는 앱의 가격이 이 제한을 전제로 합니다.
- **Nodaro는 피커 제한을 강제합니다.** 게시자는 **배경**, **분위기**, **인물** 같은 피커를 카탈로그의 일부로 제한할 수 있습니다. 제한은 `snapshotSettings.presentationSettings.cardMeta[nodeId]`에 있습니다. 단일 차원 피커는 `pickerAllowedValues`를 쓰고, 다중 차원 피커는 `pickerAllowedValuesByField`(`{ field: [ids] }`)를 씁니다. 목록에 없는 값은 API 실행을 포함한 앱의 모든 실행에서 `400 validation_error`로 거부됩니다. 선택을 취소하려면 필드를 빼거나 빈 값을 보내세요. 카탈로그 기본값은 보내지 마세요.
- **`snapshotNodes[i].data`로 기본값을 미리 채우세요.** 그러면 아무것도 바꾸지 않은 사용자도 의미 있는 실행 결과를 얻습니다.
- **`estimatedCredits`를 보여 주세요.** 그러면 사용자가 실행하기 전에 비용을 알 수 있습니다. `perMinuteCredits`나 `perItemCredits`가 0이 아니면, 가격은 녹음 길이나 목록 항목 수에 따라 늘어납니다. 고정 부분에 그 부분들을 더해 보여 주세요. `estimatedCredits`에는 최종 렌더링도 포함되며, `runEstimatedCredits`와 그 분당·항목당 부분은 실행만의 가격입니다. `maxEstimatedCredits`가 있으면 가격은 사용자가 고르는 값에 따라 달라집니다. “5부터 최대 9까지”처럼 범위로 보여 주세요. “최대”는 노출된 모든 필드에 걸쳐 한 번의 실행에 들 수 있는 가장 큰 비용으로, 게시자가 허용 값을 정한 곳에서는 그 값, 각 모델 자체의 옵션, 사용자가 제공하는 레퍼런스 클립의 가장 긴 길이를 기준으로 합니다. 이 범위가 생기기 전에 게시된 버전에는 다시 게시할 때까지 `max*` 필드가 없습니다.
- `maxRunsPerUserPerDay`가 설정되어 있으면 “오늘 5회 중 2회 사용” 같은 식으로 **일일 한도를 보여 주세요.** 예상치 못한 `429`를 피할 수 있습니다.

### 4단계: 실행 시작과 폴링
토큰으로 실행을 시작하세요.

```bash
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" }
}
}'
```

응답은 `202 Accepted`입니다.

```json
{ "executionId": "exec-uuid", "runId": "run-uuid", "status": "pending" }
```

| 선택적 본문 필드 | 기능 |
| --- | --- |
| `version` | 앱의 특정 버전을 실행합니다. 기본값은 최신 버전입니다. |
| `inputs` | SDK와 CLI가 보내는 형태처럼, 입력 이름을 값에 대응시킨 평면 맵으로 된 앱의 입력입니다. `inputOverrides`는 `inputs` 위에 필드 단위로 적용되며, 둘이 같은 필드를 설정하면 `inputOverrides`가 우선합니다. 노출된 슬라이더처럼 숫자 필드는 숫자나 숫자 문자열을 받습니다. `"0.4"`는 `0.4`로 도착합니다. |
| `runId` | 미리 만들어 둔 초안 실행에 이 실행을 연결합니다. 처음 만들 때는 무시해도 됩니다. |

`inputOverrides`로는 `promptPrefix`처럼 앱이 노출하지 않는 노드 필드도 설정할 수 있습니다. [프롬프트 앞뒤 텍스트](https://nodaro.ai/docs/concepts/prompt-pre-post-text)를 참고하세요. 다만 **Webhook 출력** 주소처럼 외부로 보내는 노드가 데이터를 보내거나 가져오는 위치나, **하위 워크플로** 노드가 실행하는 워크플로는 절대 바꿀 수 없으며, 이런 실행은 `400 locked_field`로 거부됩니다.

그런 다음 실행을 폴링하세요.

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

```jsonc
{
"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..."
}
}
```

- **약 2초마다 폴링하세요.** `execution.status`가 `completed` 또는 `failed`가 되면 멈추세요.
- 실행 중에는 `totalNodes` 중 `completedNodes`로 **진행 상황을 보여 주세요.**
- **실패하면** `execution.errorMessage`를 보여 주세요.

실행의 소유자는 `PATCH /v1/app/{slug}/runs/{runId}`로 `name`, `inputValues`, `hiddenNodes`, `nodeStates`만 바꿀 수 있습니다. 서버는 그 밖의 필드를 무시하며, 이 네 가지 중 아무것도 지정하지 않은 `PATCH`에는 `400 bad_request`로 응답합니다.

실행 보기에는 실행 결과에 대한 내 편집이 `nodeStateEdits`로 담깁니다. 이것은 `PATCH`가 `nodeStates`로 설정하는 맵입니다. `PATCH`는 그 맵 전체를 바꿉니다. 이미 저장된 모든 편집을 내 편집과 합쳐서 보내세요. 빠뜨린 편집은 사라집니다.

### 5단계: 결과 보여 주기
주요 결과는 `thumbnailNodeId`가 가리키는 노드의 출력입니다. `execution.nodeStates[thumbnailNodeId].output`에서 다음 키를 순서대로 시도해 URL을 읽으세요: `url`, `imageUrl`, `videoUrl`, `audioUrl`, `resultUrl`. 텍스트 결과라면 마지막으로 `text`를 읽고, 링크가 아니라 텍스트로 보여 주세요.

`GET /v1/nodes/{type}`에서 얻은 노드의 `outputType`에 따라 렌더링하세요.

| `outputType` | 렌더링 방법 |
| --- | --- |
| `image` | 이미지 |
| `video` | 컨트롤이 있는 동영상 플레이어 |
| `audio` | 컨트롤이 있는 오디오 플레이어 |
| `text` | 서식이 유지되는 텍스트 또는 Markdown |
| `data` | JSON 뷰어, 또는 불투명 데이터 |

`thumbnailNodeId`가 `null`이면 워크플로 순서상 마지막 노드를 사용하거나, 비어 있지 않은 모든 출력을 보여 주세요.

### 실행의 최종 렌더링
앱의 [EDL 적용](https://nodaro.ai/docs/nodes/video/apply-edl) 렌더가 미리 보기를 만들면, 앱 페이지에서 그 렌더에 **최종 렌더링**을 제공합니다. 코드에서는 `{ "renderNodeId": "<render node id>" }`와 함께 `POST /v1/app/{slug}/runs/{runId}/render-final`을 보내세요. 서버는 이번 실행에서만 그 렌더를 최종 품질로 렌더링한 다음, 그 뒤의 모든 노드를 한 번 실행합니다. 렌더보다 앞의 노드는 다시 실행되거나 청구되지 않습니다.

응답은 `{ executionId, runId, status: "pending" }`과 함께 `202`입니다. 8자 이상의 `Idempotency-Key` 헤더를 보내면, 같은 요청을 다시 보냈을 때 `"deduped": true`와 함께 `200`으로 응답하고 아무것도 시작하지 않습니다.

- **비용을 내는 사람.** 최종 렌더링은 앱을 실행하는 사람에게 노드 자체 가격으로, 앱 실행과 별도인 두 번째 실행으로 청구됩니다. 앱 제작자의 수수료는 적용되지 않습니다. 최종 렌더링은 실행 기록에 행을 추가하지 않으며, 앱의 일일 실행 한도에도 포함되지 않습니다.
- **내 검토가 반영됩니다.** 위의 `PATCH`로 실행에 저장한 편집 계획 검토가 최종 렌더링에 적용됩니다.
- **결과가 나타나는 곳.** 최종 렌더링에 결과가 생기면 `GET /v1/app/{slug}/runs/{runId}`가 미리 보기의 상태 위에 그 결과를 `execution.nodeStates`로 보여 주고, 각 상태에 `"fromRenderFinal": true`를 표시합니다. 실행에는 `finalExecution: { id, status, completedNodes, totalNodes, errorMessage, completedAt, creditsUsed }`도 담기며, 최종 렌더링을 요청하지 않았으면 `null`입니다. 실행 자체의 `creditsUsed`는 앱 실행의 값으로 남습니다.
- **에피소드 다듬기 노드도 같은 방식으로 마무리합니다.** **렌더링 전에 검토**가 켜진 [에피소드 다듬기](https://nodaro.ai/docs/nodes/video/tighten-episode) 노드는 자체 미리 보기를 가지므로, 그 노드 자체를 `renderNodeId`로 지정하세요. 최종 렌더링은 에피소드의 편집에 대한 내 검토를 반영해 최종 품질로 렌더링한 다음, 자막과 배경 음악을 추가하며, 에피소드 다듬기 수수료는 붙지 않습니다. 그 뒤의 노드는 앱 실행에서 이미 미리 보기로 실행되었고, 최종 렌더링에서 다시 실행됩니다. 마무리되는 것은 미리 보기 결과뿐입니다. 이미 최종인 결과나 목록 항목마다 한 번씩 실행된 결과는 `render_final_not_preview`로 거부됩니다.
- **실패한 최종 렌더링은 표시되지 않습니다.** `finalExecution`이 그 `status`와 `errorMessage`를 알려 주고, 보기에는 미리 보기가 다시 표시됩니다.

| 상태 | 코드 | 의미 |
| --- | --- | --- |
| 400 | `not_a_render` | `renderNodeId`가 앱의 렌더 노드가 아닙니다. |
| 400 | `preview_render_nested` | 앱이 컴포넌트입니다. |
| 402 | `insufficient_app_credits` | 내 크레딧으로 최종 렌더링 비용을 감당할 수 없습니다. |
| 404 | `not_found` | 이 앱에 그런 내 실행이 없습니다. |
| 409 | `continuation_not_completed` | 실행이 아직 실행되지 않은 초안이거나, 완료되지 않았습니다. |
| 409 | `already_running` | 이 실행의 최종 렌더링이 아직 렌더링 중입니다. 그 ID는 `executionId`에 있습니다. |
| 503 | `run_finals_unavailable` | 실행의 이전 최종 렌더링을 지금 읽을 수 없습니다. 다시 시도하세요. 아무것도 시작되지 않았습니다. |

모든 거부는 무엇이든 기록되거나 청구되기 전에 일어납니다.

### 실행 삭제
```bash
curl -X DELETE https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
  -H "Authorization: Bearer $NODARO_API_TOKEN"
```

삭제하면 실행이 사용자의 보관함으로 옮겨지고 `{ "success": true, "archived": true }`로 응답합니다. 아무것도 파기되지 않으므로, 자동화가 잘못된 실행을 삭제해도 사용자의 데이터는 사라지지 않습니다. 실행 복원과 영구 삭제는 Nodaro 앱에서만 할 수 있으므로, 통합 코드에서는 둘 다 시도하지 마세요.

## 인증
모든 실행에는 Bearer 토큰이 필요합니다. 누가 실행을 소유하고 비용을 지불할지에 따라 종류를 고르세요.

| 만드는 것 | 사용할 것 |
| --- | --- |
| 개인 도구, 에이전시 대시보드, 내부 자동화 | 개인 API 토큰. |
| 고객이 자신의 Nodaro 계정을 연결하는 SaaS | [OAuth](https://nodaro.ai/docs/developers/oauth). |
| 익명 방문자를 위해 앱을 실행하는 공개 도구 | 개인 API 토큰. 내가 비용을 지불하므로, 방문자별 실행 횟수를 내 쪽에서 제한하세요. |

**개인 API 토큰.** 모든 실행을 내 계정이 소유하고 지불합니다. **설정 › API 토큰**에서 **토큰 만들기**로 토큰을 만들고, 한 번만 복사해 서버 시크릿으로 저장하세요. 토큰은 내가 폐기할 때까지 유효합니다. [인증](https://nodaro.ai/docs/developers/api/authentication)을 참고하세요.

**OAuth.** 각 사용자의 계정이 자신의 실행을 소유하고 지불합니다. 개발자 앱을 등록하고, 실행을 시작하는 `workflows:execute`와 실행을 폴링하는 `jobs:read` 스코프로 사용자를 동의 화면에 보내세요. 내 서버에서 코드를 90일 동안 유효한 액세스 토큰으로 교환하세요. 호출이 `403 insufficient_scope`로 응답하면, 더 넓은 스코프로 사용자가 동의 과정을 다시 거치게 하세요.

## 토큰은 내 서버에 보관
**토큰은 절대 브라우저에 전달되면 안 됩니다.** 번들러는 `VITE_` 또는 `NEXT_PUBLIC_` 접두사가 붙은 모든 환경 변수를 배포하는 JavaScript에 그대로 넣으며, 그러면 개발자 도구를 쓰는 누구나 이를 읽고 내 크레딧을 쓸 수 있습니다.

```text
[Browser]  --HTTPS-->  [Your server or edge function]  --HTTPS-->  Nodaro API
(holds NODARO_API_TOKEN)
```

| 스택 | 토큰을 두는 곳 |
| --- | --- |
| Lovable과 Supabase | Supabase Edge Function 시크릿: `supabase secrets set NODARO_API_TOKEN=...` |
| Next.js | `NEXT_PUBLIC_` 접두사 없이 `process.env.NODARO_API_TOKEN`을 쓰는 서버 라우트 핸들러 |
| SvelteKit, Remix, Nuxt | SvelteKit의 `$env/static/private` 같은 서버 전용 환경 변수 |
| Vercel 또는 Netlify Edge Function | 클라이언트에 노출되지 않는 프로젝트 환경 변수 |
| Cloudflare Worker | Worker 시크릿: `wrangler secret put NODARO_API_TOKEN` |

브라우저 코드는 내 서버와만 통신하므로, Nodaro의 허용된 출처에 내 도메인을 추가할 필요가 없습니다. 브라우저에서 OAuth로 Nodaro를 직접 호출한다면, 개발자 앱의 **허용된 출처**에 내 출처를 등록하세요.

## 오류
오류는 `{ "error": { "code": "...", "message": "..." } }` 형태입니다.

| 상태 | 코드 | 원인 | 해결 방법 |
| --- | --- | --- | --- |
| `400` | `validation_error` | 형식이 잘못된 `inputOverrides`, `allowedValues`나 피커의 허용 목록에 없는 값, 또는 형식이 잘못된 slug입니다. | 요청 본문을 수정하세요. |
| `400` | `input_too_long` | 텍스트 입력이 필드의 `maxLength`보다 깁니다. | 항목이 알려 주는 제한까지 텍스트를 줄이세요. |
| `400` | `locked_field` | 오버라이드가 **Webhook 출력** 주소처럼 외부로 보내는 노드의 대상을 지정했거나, UGC 노드가 실행에서 받지 않는 필드를 지정했습니다. | 해당 오버라이드를 제거하세요. 데이터를 보내고 가져오는 위치는 앱이 정하며, UGC 노드는 크리에이터 카드의 필드와 동영상 길이만 받습니다. |
| `400` | `ugc_photo_required` | UGC 크리에이터가 **내 사진 사용**으로 설정되어 있는데, 실행에 실행하는 사람 본인의 사진이 없습니다. | 실행하는 사람에게 사진을 요청하거나, 카드를 **AI 크리에이터**로 바꾸세요. |
| `401` | `unauthorized` | 토큰이 없거나, 만료되었거나, 폐기되었습니다. | 새 토큰을 만들거나 다시 인증하세요. |
| `402` | `insufficient_app_credits` | 토큰에 연결된 계정의 크레딧이 부족합니다. | 크레딧을 추가하거나 요금제를 변경하세요. |
| `402` | `insufficient_credits` | UGC 동영상을 만드는 앱에서, 클립을 렌더링하는 동안 동영상이 예약할 수 있는 금액을 잔액이 감당하지 못합니다. | 크레딧을 추가하거나 더 짧은 동영상을 만드세요. |
| `403` | `insufficient_scope` | OAuth 토큰에 `missingScope`에 표시된 스코프가 없습니다. | 더 넓은 스코프로 다시 인증하세요. |
| `404` | `not_found` | slug나 실행이 존재하지 않거나, 앱이 비활성화되었거나, 실행의 실행 기록이 그 실행 소유자의 것이 아닙니다. | slug를 확인하고 “앱을 사용할 수 없음”을 표시하세요. |
| `429` | `rate_limit_exceeded` | 사용자당 앱의 일일 실행 한도에 도달했습니다. | “일일 한도에 도달함”을 표시하세요. |
| `500` | `internal_error` | 서버 오류입니다. | 잠시 후 한 번 다시 시도하고, 계속되면 신고하세요. |

## 참고 템플릿
### 앱을 프록시하는 Supabase Edge Function
```ts
// supabase/functions/nodaro-run/index.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 })
})
```

### 브라우저 코드: 읽기, 실행, 폴링
```ts
// 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
```

이 템플릿에서 브라우저는 노드 ID를 키로 한 값을 보내고, Edge Function은 이를 `inputOverrides`로 전달합니다.

### 인터페이스 코드를 생성하기 전 체크리스트
- `GET {BASE}/v1/app/{slug}`가 입력 스키마와 기본값을 반환했습니다.
- `inputItems`의 각 `nodeId`에 대해 `snapshotNodes`에서 `type`을 알고 있습니다.
- 각 노드 타입에 대해 `GET {BASE}/v1/nodes/{type}`으로 카테고리, `outputType`, 모델을 확인했습니다.
- 2단계의 규칙으로 양식 필드 목록을 만들었습니다.
- 결과 노드(`thumbnailNodeId` 또는 마지막 노드)와 그 `outputType`을 알고 있습니다.
- 개인 API 토큰이나 개발자 앱 자격 증명이 있습니다.
- 시크릿은 내 서버에 있습니다.

## 자주 묻는 질문

### 내 웹사이트에 Nodaro 미니앱을 임베드하려면 어떻게 하나요?

미니앱을 열고 내 미니앱을 선택한 다음, 앱 카드에서 임베드를 클릭하세요. 임베드 허용 도메인에 내 사이트의 도메인을 추가하고, 임베드 코드 복사를 클릭한 다음 iframe을 페이지에 붙여 넣으세요. 도메인을 하나 이상 추가하기 전까지는 임베드가 차단됩니다.

### 나만의 미니앱 인터페이스를 만들려면 어떤 엔드포인트가 필요한가요?

입력을 알아내는 GET /v1/app/{slug}와 GET /v1/nodes/{type}이 필요하며, 둘 다 공개 엔드포인트입니다. 그다음 실행을 시작하는 POST /v1/app/{slug}/run과 실행을 폴링하는 GET /v1/app/{slug}/runs/{runId}가 필요하며, 둘 다 토큰이 있어야 합니다.

### 미니앱 실행이 끝나면 Nodaro가 내 서버를 호출하나요?

아니요. 미니앱 실행은 콜백을 보내지 않습니다. 상태가 completed 또는 failed가 될 때까지 약 2초마다 실행을 폴링하세요.

### 임베드한 미니앱의 실행 비용은 누가 지불하나요?

실행 주체인 계정이 지불합니다. 개인 API 토큰을 사용하면 모든 실행을 내 계정이 지불합니다. OAuth를 사용하면 각 사용자의 계정이 자신의 실행을 지불합니다.

### API로 미니앱 실행을 삭제할 수 있나요?

네. 실행에 DELETE를 보내면 보관 처리되며, 사용자는 Nodaro 앱에서 이를 복구할 수 있습니다. 복원과 영구 삭제는 Nodaro 앱에서만 할 수 있습니다.
