---
title: 구조화 출력
description: Format run에 JSON Schema를 바인딩하고 typed·검증된 JSON을 받으세요 — 지원 스키마 규칙, run 이후 투영, 모든 실패 모드입니다.
---

기본적으로 완료된 Format run은 미디어와 한 단락의 텍스트를 건넵니다. 사람에게는
괜찮고 데이터베이스에는 어색합니다. 스키마를 바인딩하면 typed 객체를 받습니다 —
같은 run이지만, 자체 레코드에 바로 쓸 수 있는 형태입니다.

```json
{
  "headline": "Aurora Headphones, all day quiet.",
  "hero_image": {
    "type": "image",
    "url": "https://media.sume.com/artifacts/artf_.../image-0.png",
    "content_type": "image/png",
    "file_name": "image-0.png",
    "size_bytes": null,
    "width": null,
    "height": null,
    "duration_ms": null,
    "expires_at": null
  },
  "alt_text": "Aurora Headphones on a warm studio backdrop."
}
```

정확한 요청·응답 스키마는 라이브 OpenAPI
(`https://api.sume.com/reference/json`)에서 가져옵니다. 여기 표는 읽기 쉬운 요약이며,
두 번째 스키마가 아닙니다.

## `input`과는 다른 종류의 것입니다

run 요청에는 JSON 모양의 필드가 둘 있고, 둘은 전혀 다르게 동작합니다. 이 둘을 헷갈리는 것이
첫 연동이 어긋나는 가장 흔한 이유입니다.

| | [`input`](/formats/call#input-caller-data) | `output_schema` |
|---|---|---|
| 정체 | 호출자 데이터 | receipt에 대한 계약 |
| 방향 | 여러분 → run | run → 여러분 |
| 형태 | 백엔드에 편한 아무 JSON 객체 | 아래 「지원 스키마」 부분집합 안의 JSON Schema |
| 검사 대상 | 객체인지, 키 개수, 바이트 크기 | 부분집합의 모든 규칙 |
| Sume이 예상하지 않은 형태 | 그대로 실행됩니다. 모르는 키는 그냥 데이터입니다 | `400 output_schema_invalid` — 아무것도 실행·청구되지 않습니다 |
| 도착지 | 에이전트 프롬프트 안의 울타리 친 데이터 블록 | run 이후의 projection |

즉 `input`은 유연한 concat이고 `output_schema`는 엄격한 typed receipt입니다. `input`은 얼마든지
느슨하게 둘 수 있지만, `output_schema`는 전혀 느슨하게 만들 수 없습니다 — `strict: false`로도
안 되고, 다른 탈출구도 없습니다.

둘은 만나지 않습니다. **projection은 여러분의 `input`을 절대 보지 못하므로**(아래 참고),
보낸 값은 run이 마무리 텍스트에서 스스로 되풀이하지 않는 한 `output`으로 돌아올 수 없습니다.

## `output`은 어디에서 오는가

채팅 completion과 다른 부분이며, 스키마를 설계하기 전에 이해하는 것이 좋습니다.
경로가 두 가지이고, receipt가 어느 쪽이었는지 알려줍니다.

```text
1. the run executes in its sandbox            <- the recipe, your instruction, your input
2. the run submits your object                <- filled_by: "agent"  (preferred)
   ...or does not, and then:
3. the result is harvested                    <- generated media + the run's closing text
4. the harvest is projected onto your schema  <- filled_by: "projection"
5. either way: gated, then `output` appears on the receipt
```

**`filled_by: "agent"` — run이 직접 답한 경우.** 스키마는 run에게 도구로 전달되며,
run은 끝내기 전에 그 도구를 호출해야 합니다. 스키마가 곧 도구의 인자 형태입니다.
작업을 한 모델이, 자기가 무엇을 왜 만들었는지 아직 기억하는 상태에서 객체를 채웁니다.
`input`과 `instruction`도 볼 수 있습니다 — run의 일부이기 때문입니다.

**`filled_by: "projection"` — 폴백.** run이 유효한 객체를 제출하지 않고 끝나면, 이후 별도의
constrained pass가 run이 남긴 것으로 객체를 만듭니다. 그 pass는 temperature 0의 OpenAI
strict `json_schema` completion이며, 정확히 두 가지 사실만 주어집니다.

| 사실 | 내용 |
|---|---|
| run이 생성한 미디어 | run이 만든 모든 artifact와 그 durable URL·메타데이터입니다. |
| run의 마무리 텍스트 | 마지막 assistant 메시지이며, 앞 8000자로 잘립니다. |

여러분의 `input`도, `instruction`도, Format 본문도, run의 중간 단계도 **아닙니다.**
이 경로에서는 `output`에 담고 싶은 것이 위 두 사실 중 하나에 있어야 합니다.

그래서 `filled_by`를 읽을 가치가 있습니다. run이 직접 쓴 객체인지, 남은 것으로 재구성한
객체인지의 차이이며, 대부분의 의외의 결과가 여기서 설명됩니다.

- **projection 경로에서는 자기 식별자가 왕복하지 않습니다.** `input`으로 보낸 `order_id`는
  거기서 보이지 않습니다. 식별자는 여러분 쪽에 `run.id`나 보낸 `Idempotency-Key`로 키를
  잡아 두고, `output`에는 run이 만든 것만 담으세요.
- **두 경로 모두 `output`에는 지어낸 것이 없습니다.** 둘 다 아래의 같은 게이트를 거친 뒤에야
  전달됩니다. run이 직접 썼다고 해서 더 신뢰받지 않습니다.
- **projection 경로에서 비어 있는 텍스트 필드는 신호입니다.** projection은 `input`을 보지
  못하므로, 제목·설명·식별자를 브리프에서 가져오는 스키마는 미디어 필드만 찬 채 그 자리들이
  `null`로 돌아옵니다. `filled_by: "projection"` + 빈 문구는 Format이 카피를 빼먹은 모양이
  아니라, run이 일찍 멈춘 모양입니다.

run을 자동으로 채점한다면 — 스모크 매트릭스, 파트너 연동, 대시보드 — 전달로 세기 전에
`filled_by`를 읽고, 비디오라면 옆의 숫자가 아니라 파일 자체를 확인하세요.
`primary_output_url`에 `ffprobe` 한 번은 2초면 끝나고, 조립된 완성본과 그렇게 생긴 클립을
가르는 유일한 방법입니다.

따라오는 실용 규칙은 그대로입니다: **Format이 실제로 만드는 것만 요구하세요.** 레시피가
만들지 않는 필드를 요구하는 스키마는 projection 경로에서 매번 실패하며,
`output_error`를 읽기 전까지는 조용합니다.

## 스키마 바인딩

철자 두 가지, 동작은 하나입니다. 클라이언트에 맞는 쪽을 쓰세요.

**네이티브 — `output_schema`:**

```json
{
  "instruction": "Make one hero image for the linked product.",
  "input": { "product_url": "https://example.com/p" },
  "output_schema": {
    "name": "acme/promo-hero/v1",
    "strict": true,
    "schema": {
      "type": "object",
      "additionalProperties": false,
      "required": ["headline", "hero_image", "alt_text"],
      "properties": {
        "headline": { "type": "string" },
        "hero_image": { "$ref": "SumeMediaFile#" },
        "alt_text": { "type": ["string", "null"] }
      }
    }
  },
  "primary_output_key": "hero_image"
}
```

**OpenAI 형태 별칭 — `response_format`:**

```json
{
  "response_format": {
    "type": "json_schema",
    "json_schema": {
      "name": "acme/promo-hero/v1",
      "strict": true,
      "schema": { "...": "as above" }
    }
  }
}
```

`response_format`은 receipt에서 `output_schema`로 정규화되므로, 읽어 오는 값은
항상 네이티브 철자입니다. **둘 다 보내면 `400 invalid_request`입니다.**

이 별칭은 **Chat Completions** 철자를 따릅니다 — `type`이 맨 위, 바인딩은 `json_schema` 아래에
중첩됩니다. OpenAI *Responses* API는 같은 필드를 `text.format: { type, name, strict, schema }`로
평평하게 폅니다. 그 평평한 형태는 여기서 **받지 않습니다.** 클라이언트가 Responses 형식 본문을
만든다면 필드를 `output_schema`로 올리거나 `json_schema` 아래로 다시 중첩하세요.

| Field | Rules |
|---|---|
| `name` | 필수입니다. 1–64자, `^[A-Za-z0-9._/-]+$`. 네임스페이스를 두세요 — 모든 receipt에 나타납니다. |
| `strict` | 기본값 `true`. [지원 스키마](#지원-스키마) 아래 메모를 참고하세요. |
| `schema` | 필수입니다. 지원 부분집합 안의 JSON Schema 객체입니다. |

Format은 대시보드에 바인딩된 자체 기본 스키마를 가질 수도 있습니다. 요청마다의
`output_schema`가 그 run에 대해 덮어씁니다. 어느 쪽이 적용됐는지는 receipt의
`output_schema.source`에 있습니다.

| `source` | Meaning |
|---|---|
| `default` | 바인딩된 것이 없습니다. `output`은 [내장 스키마](#내장-스키마)입니다. |
| `action_default` | Format 자체의 바인딩된 스키마입니다. (`action_`은 wire 철자이며 [Scheduled](/agents/actions)와 공유합니다.) |
| `request_override` | 이 run 요청에 보낸 `output_schema`입니다. |

## 지원 스키마

스키마는 OpenAI strict-mode 부분집합을 만족해야 합니다. 권장이 아니라 실제
부분집합이며, 밖인 스키마는 제출 시 `400 output_schema_invalid`와 각 문제를 가리키는
`details.violations[]`로 거절됩니다. 아무것도 실행되지 않으므로 청구도 없습니다.

### 지원되는 키워드 전체

부분집합은 **허용 목록**으로 동작합니다. 목록에 없는 키워드는 조용히 무시되는 것이 아니라
위반입니다 — 무시된 제약은 Sume이 지킨다고 약속할 수 없는 스키마이기 때문입니다.

| 그룹 | 허용 |
|---|---|
| 구조 | `type`, `properties`, `required`, `additionalProperties`, `items`, `$defs`, `$ref`, `anyOf` |
| 값 | `enum`, `const` |
| 문자열 | `format`, `pattern`, `minLength`, `maxLength` |
| 숫자 | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
| 배열 | `minItems`, `maxItems` |
| 주석 | `title`, `description`, `default`, `examples`, `$schema`, `$id` |

타입은 `string`, `number`, `integer`, `boolean`, `object`, `array`, `null`입니다.

그 밖은 모두 거절됩니다. 실제 연동에서 자주 걸리는 것들입니다.

| 거절 | 대신 |
|---|---|
| `oneOf` | `anyOf`. 목록에 있는 것은 `anyOf`뿐인데, OpenAPI에서 옮긴 스키마는 반사적으로 `oneOf`를 씁니다. |
| `allOf` | 분기를 하나의 object로 평평하게 펴세요. |
| `not`, `if` / `then` / `else`, `dependentRequired`, `dependentSchemas` | 표현할 수 없습니다. 대안을 `anyOf`로 모델링하거나, `output`을 읽은 뒤 여러분 쪽에서 검증하세요. |
| `nullable: true` | nullable union: `"type": ["string", "null"]`. |
| `patternProperties`, `propertyNames`, `unevaluatedProperties`, `additionalItems` | 원하는 속성을 선언하세요. 나머지는 `additionalProperties: false`가 덮습니다. |

### 모든 노드에 `type`이 필요합니다

또는 `$ref`나 `anyOf`가 필요합니다. "무엇이든"을 뜻하는 적법한 JSON Schema인
`{ "description": "…" }`도 `missing_type` 위반입니다. `array`는 `items`도 선언해야 합니다.

`$ref`와 `anyOf`는 각각 자기가 놓인 노드를 **단락(short-circuit)시킵니다.** 옆에 둔 형제
키워드는 허용 목록 검사만 받을 뿐 아무 의미가 없습니다. 제약은 `anyOf` 분기 안이나 `$defs`
항목 안에 넣고, `$ref` 옆에 두지 마세요.

### 루트는 object여야 합니다

```json
{ "type": "object", "additionalProperties": false, "required": [], "properties": {} }
```

루트의 `type`은 정확히 `"object"` 하나여야 하므로 `{ "type": ["object", "null"] }`도
거절됩니다. 최상위 array, string, union도 거절됩니다. 감싸세요.

```json
// rejected
{ "type": "array", "items": { "type": "string" } }

// accepted
{
  "type": "object",
  "additionalProperties": false,
  "required": ["captions"],
  "properties": { "captions": { "type": "array", "items": { "type": "string" } } }
}
```

### 모든 object에 `additionalProperties: false`가 필요합니다

루트만이 아니라 스키마의 모든 object — array `items` 안과 `$defs` 안을
포함합니다.

```json
// rejected: the nested object is open
{
  "type": "object",
  "additionalProperties": false,
  "required": ["scene"],
  "properties": {
    "scene": { "type": "object", "properties": { "title": { "type": "string" } } }
  }
}
```

### 모든 속성은 `required`에 나열되어야 합니다

선택 속성은 없습니다. 선언한 속성은 반드시 있어야 하는 속성입니다.

선택성은 대신 **nullable union**으로 표현하세요.

```json
// rejected: `subtitle` is declared but not required
{
  "type": "object",
  "additionalProperties": false,
  "required": ["title"],
  "properties": {
    "title": { "type": "string" },
    "subtitle": { "type": "string" }
  }
}

// accepted: `subtitle` is always present, and may be null
{
  "type": "object",
  "additionalProperties": false,
  "required": ["title", "subtitle"],
  "properties": {
    "title": { "type": "string" },
    "subtitle": { "type": ["string", "null"] }
  }
}
```

다른 곳에서 옮긴 스키마가 가장 자주 걸리는 규칙입니다. `null`을 "run이 여기에 둘
것이 없었다"로 읽으세요. 그것이 `optional`이 원하던 경우입니다.

### 크기와 중첩 한도

| Limit | Value | Violation |
|---|---|---|
| Nesting depth | 10 levels | `max_depth` |
| Total properties | 문서 전체 합산 5000 | `max_properties` |
| Enum values | enum 하나당 1000 | `max_enum_values` |
| Total string length | 문서 안의 모든 속성 이름·키·문자열 값을 합산해 120,000자 | `max_string_length` |

마지막 항목은 필드별 상한이 아니라 문서 전체 예산이라서, 큰 스키마에 긴 `description` 주석을
달면 개별 문자열이 특별히 길지 않아도 예산을 다 쓸 수 있습니다.

### `$ref`는 제한됩니다

해석되는 대상은 두 가지뿐입니다.

| Target | Use |
|---|---|
| `#/$defs/*` | 스키마 문서 **루트**에 선언한 자체 정의입니다. |
| `SumeMediaFile#` | Sume의 미디어 형태입니다. [아래](#sumemediafile)를 참고하세요. |

외부 `$ref` — URL, 형제 문서, `#/components/...` — 는 거절됩니다. `$ref: "#"`도 마찬가지입니다.
OpenAI strict 모드는 그 방식의 루트 재귀를 허용하지만 Sume은 허용하지 않습니다. 루트 `$defs`에
대응 항목이 없는 `#/$defs/*` 대상도 거절되며, `$defs` 블록을 루트가 아니라 하위 스키마 안에
넣었을 때 이것에 걸립니다.

이름 붙인 정의를 통한 재귀는 괜찮습니다. `$defs` 항목은 자기 자신을 `$ref`할 수 있습니다.
depth 한도는 문서의 실제 중첩만 세므로, 자기 참조 정의는 depth를 쓰지 않습니다.

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["scenes"],
  "properties": {
    "scenes": { "type": "array", "items": { "$ref": "#/$defs/scene" } }
  },
  "$defs": {
    "scene": {
      "type": "object",
      "additionalProperties": false,
      "required": ["caption", "clip"],
      "properties": {
        "caption": { "type": "string" },
        "clip": { "$ref": "SumeMediaFile#" }
      }
    }
  }
}
```

### `strict: false`는 위 규칙을 완화하지 않습니다

수락·저장되지만 위 부분집합에 대해 아무것도 바꾸지 않습니다. 부분집합 밖 스키마는
`strict`가 `true`든 `false`든 거절됩니다. 탈출구로 쓰지 마세요 — 없습니다.

"유효한 JSON이면 형태는 아무래도 좋다"는 OpenAI의 JSON 모드(`{"type": "json_object"}`)에
해당하는 것도 없습니다. 스키마를 바인딩하거나 [내장 스키마](#내장-스키마)를 쓰거나,
선택지는 이 둘입니다.

### `details.violations[]` 읽기

각 항목은 `{ path, rule, message }`입니다. `path`는
`#/properties/scenes/items/properties/clip` 같은 JSON Pointer 형식 위치이고, `message`는 사람이
읽으라고 쓰였으며 바뀔 수 있습니다. `rule`은 `switch`로 분기해도 안전한 안정적인 소문자
토큰입니다.

| `rule` | 뜻 |
|---|---|
| `root_must_be_object` | 루트가 없거나, object가 아니거나, `type`이 정확히 `"object"`가 아닙니다. |
| `not_an_object` | 스키마 노드가 JSON object가 아닙니다. |
| `missing_type` | 노드에 `type`·`$ref`·`anyOf`가 없습니다. |
| `unsupported_type` | 위에 나열한 일곱 가지 밖의 `type`입니다. |
| `unsupported_keyword` | 허용 목록 밖 키워드입니다. |
| `additional_properties_false` | `additionalProperties: false`가 없는 object 노드입니다. |
| `required_completeness` | 선언한 속성이 `required`에 없거나, `required` 항목에 대응 속성이 없습니다. |
| `missing_items` | `items`가 없는 `array` 노드입니다. |
| `unsupported_ref` | `#/$defs/<name>`도 `SumeMediaFile#`도 아닌 `$ref`이거나, 존재하지 않는 정의를 가리킵니다. |
| `invalid_defs` | `$defs`가 있지만 이름 붙인 스키마의 object가 아닙니다. |
| `max_depth`, `max_properties`, `max_enum_values`, `max_string_length` | 위 「크기와 중첩 한도」입니다. |

첫 번째 문제만이 아니라 모든 문제가 보고되므로, `400` 한 번이면 스키마를 고치기에 충분합니다.

## OpenAI structured outputs에서 넘어온다면

`response_format: { type: "json_schema", … }`나 Responses API의 `text.format`을 써 봤다면, 아는
것 대부분이 그대로 옮겨집니다. 부분집합 규칙은 같은 가이드에서 인용한 같은 규칙입니다. 다른
것은 *스키마가 어디에 적용되는가*입니다.

| OpenAI | Sume | 메모 |
|---|---|---|
| `response_format.json_schema` | `output_schema`, 또는 `response_format` 그대로 | Chat Completions 철자만 받습니다. `text.format`은 받지 않습니다. |
| `json_schema.name` | `output_schema.name` | 양쪽 모두 필수입니다. 네임스페이스를 두세요. 모든 receipt에 남습니다. |
| `json_schema.strict` | `output_schema.strict` | 수락되고 기본값은 `true`이며, 아무것도 바꾸지 않습니다 — 부분집합은 언제나 강제됩니다. |
| `{"type": "json_object"}` (JSON 모드) | *(없음)* | 스키마를 바인딩하거나 내장 스키마를 쓰세요. |
| 모델이 JSON을 냅니다 | run 이후 projection이 냅니다 | 스키마는 projection을 제약하며, run은 절대 제약하지 않습니다. |
| 메시지의 `refusal` | receipt의 `output_error` | 다른 메커니즘입니다. 안전 거절이 아니라 실패한 projection입니다. |
| `incomplete_details.reason: "max_output_tokens"` | *(해당 없음)* | projection은 작고 한정적이라 잘린 JSON을 처리할 일이 없습니다. |
| 스트리밍 부분 JSON | *(해당 없음)* | `output`은 종료 receipt에 한 번 나타납니다. |
| `$ref: "#"` 루트 재귀 | 거절 | 이름 붙인 `#/$defs/*` 항목으로 재귀하세요. |
| 대응 없음 | [URL 게이트](#url-게이트) | `output`의 모든 URL을 run이 실제로 만든 미디어와 대조합니다. |
| 대응 없음 | [`SumeMediaFile#`](#sumemediafile) | run의 미디어를 위한 내장 `$ref` 대상입니다. |

한 문장으로 요약하면: OpenAI에서는 **모델이 말하는 것**을 제약하고, 여기서는 **끝난 run을
어떻게 되읽을지**를 제약합니다. 나머지는 전부 여기서 따라옵니다 — 왜 스키마가 Format에게
비디오를 만들게 하지 못하는지, 왜 URL을 `output`에 지어낼 수 없는지, 왜 run이 `output: null`을
줄 수 있는지.

## `SumeMediaFile`

run의 미디어를 출력에 넣고 싶은 곳에 `{ "$ref": "SumeMediaFile#" }`로 참조하세요.
모든 필드가 필수이며, `type`과 `url`을 제외한 모든 필드는 nullable입니다.

| Field | Type | Notes |
|---|---|---|
| `type` | `"image" \| "video" \| "audio" \| "file"` | |
| `url` | string (uri) | 이 run이 실제로 만든 URL이어야 합니다 — [URL 게이트](#url-게이트)를 참고하세요. |
| `content_type` | string \| null | 예: `video/mp4`. |
| `file_name` | string \| null | |
| `size_bytes` | integer \| null | |
| `width`, `height` | integer \| null | 이미지와 비디오입니다. |
| `duration_ms` | integer \| null | 비디오와 오디오입니다. |
| `expires_at` | string (date-time) \| null | **내구성 있는 `media.sume.com` URL에서는 `null`**이며, 그것이 일반적입니다. 서명 URL이 반환될 때만 채워집니다. |

Sume 호스팅 미디어는 `media.sume.com`에서 `public, max-age=31536000, immutable`로
제공되며 만료되지 않습니다. 자체 레코드에 URL을 저장하고 나중에 렌더하세요 —
refresh 절차가 없습니다. 내구성 URL은 *공개* URL이기도 합니다. 제품에 중요하면
[artifact를 UI에 매핑하기](/cookbooks/embed-a-format#5-map-artifacts-into-your-ui)를
참고하세요.

## 내장 스키마

아무것도 바인딩하지 않으면 `output`은 `sume/action-run-output/v1`에 투영됩니다.

```json
{
  "text": "Short summary written by the Agent.",
  "images": [],
  "videos": [
    {
      "type": "video",
      "url": "https://media.sume.com/...",
      "content_type": "video/mp4",
      "file_name": "teaser.mp4",
      "size_bytes": 4210233,
      "width": 1080,
      "height": 1920,
      "duration_ms": 12000,
      "expires_at": null
    }
  ],
  "audio": [],
  "files": []
}
```

`text`는 nullable이고, 네 배열은 항상 존재하며 비어 있을 수 있습니다.

내장 스키마는 run이 만든 미디어와 최종 텍스트에서 **결정적으로** 채워집니다.
모델이 관여하지 않으므로 커스텀 스키마처럼 실패할 수 없습니다. 스키마를 설계하지
않고 미디어만 원한다면, 이것으로도 출시할 수 있습니다.

## URL 게이트

커스텀 `output`이 전달되기 전에, 안의 모든 URL이 이 run이 실제로 만든 미디어
집합과 대조됩니다. 비교는 정확한 문자열 일치입니다.

두 번의 패스가 이를 잡아냅니다. 첫 패스는 객체 안 어느 깊이에 있든, 여러분이 그 필드를
미디어로 선언했든 아니든 모든 `http(s)://` 문자열을 모읍니다. 두 번째 패스는
[`SumeMediaFile`](#sumemediafile) 형태 값의 `url`을 **내용과 무관하게** 모읍니다 — 그래서
`url`에 놓인 `"none"`이나 `""` 같은 자리표시자가 URL처럼 보이지 않는다는 이유로 빠져나갈 수
없습니다.

이 run이 만들지 않은 URL — 형태가 그럴듯한 `media.sume.com`이라도 — 은 반환되지
않고 투영이 실패합니다. 그다음 객체가 스키마에 대해 검증됩니다.

그래서 완료된 run은 실제 미디어 URL과 함께 스키마에 맞는 출력을 반환하거나,
`output: null`과 이유를 반환합니다. **스키마 형태의 추측을 반환하지 않습니다.**

### 완성본은 그 부분들 중 하나가 아닙니다

장면·컷·구간 같은 부분들로 이루어진 전체를 기술하는 스키마에는 보통 조립이 끝난 파일을
담는 필드도 있습니다. 둘은 서로 다른 파일이며, 전체 자리를 자기 부분 중 하나로 채운
receipt는 거부됩니다.

이 검사는 의도적으로 좁습니다. `"status": "succeeded"`이면서 각자의 비디오를 가진 부분이
**둘 이상** 있고, 모든 부분 **바깥**에 있는 비디오가 그중 한 파일을 다시 쓸 때만 걸립니다.
클립 하나가 정말로 완성본인 Format, 그리고 한 부분에서 의도적으로 가져온 포스터·썸네일은
그대로 통과합니다.

조립하지 못한 run에게도 정직한 답이 있으며, 그것은 "여기 장면 하나"가 아닙니다. 만든
부분들을 보고하고 완성본 필드를 `null`로 두거나, 조립의 실제 상태를 보고하세요.

### duration은 파일과 대조됩니다

[`SumeMediaFile`](#sumemediafile) 안의 `duration_ms`는 `artifacts[]`를 채우는 것과 같은
원장에서 옵니다. 그 원장이 길이를 기록해 둔 파일이라면, `output`의 값은 10% 이내로 그것과
일치해야 합니다 — 그렇지 않은 주장은 다른 파일을 설명하고 있는 것이며, 전달되지 않고
투영이 실패합니다.

원장에 길이가 없으면 아무것도 검사하지 않습니다. 거기의 `null`은 "0"이 아니라 "측정하지
않음"이고, 아무도 측정하지 않은 사실로 run을 실패시키지는 않습니다. 즉 읽어 온
`duration_ms`는 artifact 자신의 값이거나 미검증 값이며, 다른 것에서 계산된 숫자는 결코
아닙니다. 여러분이 직접 선언한 `duration_seconds` 숫자는 projection이 씁니다.

## `primary_output_key`와 `primary_output_url`

대부분의 연동에는 보여줄 것이 하나 있습니다. 이름을 붙이면 receipt가 해석해 줍니다.

```json
{ "primary_output_key": "hero_image" }
```

receipt의 `primary_output_url`은 그 키의 URL입니다. 해석 순서:

1. run 요청의 `primary_output_key`.
2. Format 자체의 `primary_output_key`.
3. 미디어 객체를 담은 첫 최상위 키, 또는 첫 요소가 미디어인 배열.

내장 스키마에서는 `videos` → `images` → `audio` → `files` 순으로 폴백합니다.

`primary_output_key`는 최대 64자입니다. 두 필드는 종료가 아닌 상태에서 `null`이고,
`output_error`가 설정된 때도 `null`입니다.

## 출력을 만들 수 없을 때

**API로 실행한 run에서는 투영 실패가 곧 run 실패입니다.** 이런 run은 무인 실행이므로,
`output`이 `null`인 `completed` receipt는 성공처럼 읽히지만 성공이 아닙니다. `status`는
`failed`가 되고 `error`는 `output_error`와 같은 이유를 담습니다. 사람이 스레드를 읽는
Agents UI에서는 같은 형태가 `completed`로 남습니다 — 거기서는 receipt가 아니라 초안입니다.

**어느 쪽이든 `artifacts[]`에는** run이 만든 모든 것이 그대로 채워집니다. 형태가 맞지
않아도 미디어는 항상 있습니다.

예외는 run을 들여다보지도 못한 투영입니다. `output_extraction_failed`는 판정이 아니라
전송 실패를 기록하므로 `completed`로 남고, 다음 읽기에서 스스로 다시 투영됩니다.

| `output_error.code` | Meaning | `details` |
|---|---|---|
| `output_schema_unsatisfied` | 투영이 스키마와 맞지 않거나, 이 run이 만들지 않은 미디어를 참조했습니다. | `rejected_urls[]`(**앞 10개만**) 또는 `violations[]`, 그리고 미디어 유형별 `harvested` 수입니다. |
| `output_extraction_failed` | 투영을 실행할 수 없었습니다. `status`는 `completed`로 남습니다. `reason`이 `harvest_unavailable`이면 run이 종료될 때 미디어를 읽지 못한 것이며, 다음 읽기에서 receipt가 채워집니다. | `reason` |

```json
{
  "status": "failed",
  "output": null,
  "output_error": {
    "code": "output_schema_unsatisfied",
    "message": "The structured output referenced media URLs that this run did not produce.",
    "details": {
      "rejected_urls": ["https://media.sume.com/artifacts/artf_fake/image.png"],
      "harvested": { "images": 1, "videos": 0, "audio": 0, "files": 0 }
    }
  },
  "error": {
    "code": "output_schema_unsatisfied",
    "message": "The structured output referenced media URLs that this run did not produce."
  },
  "primary_output_key": null,
  "primary_output_url": null,
  "artifacts": [{ "type": "image", "url": "https://media.sume.com/artifacts/artf_t1/frame.png" }],
  "next_action": "none"
}
```

이렇게 처리하세요.

- **같은 Format에서 `output_schema_unsatisfied`가 반복될 때.** 거의 항상 레시피가
  만들지 않는 미디어를 요구하는 스키마입니다. `details.harvested`를 필수 필드와
  비교하세요 — 위 예시는 이미지를 요구해 하나를 받았지만 두 번째를 원했습니다.
  필드를 nullable union으로 완화하거나, run이 만들도록 `instruction`을 바꾸세요.
- **`violations[]`가 있는 `output_schema_unsatisfied`.** 미디어가 아니라 형태입니다.
  violation이 문제 경로를 가리킵니다.
- **`output_extraction_failed`.** 일시적입니다. 먼저 run을 한 번 더 읽어 보세요 —
  `harvest_unavailable`은 그것만으로 해소됩니다. 계속된다면 **새** 멱등성 키로 run을
  재시도하세요. 이전 키는 이미 가진 receipt에 묶여 있습니다.
- **어느 코드든, UI에서.** `artifacts[]`는 여전히 있습니다. 비디오가 있는 고객에게
  오류를 보여 주기보다, 미디어를 보여 주고 형태 실패를 로그하는 편이 낫습니다.

## 제출 시 실패

아무것도 실행되기 전에 잡히는 스키마 문제입니다. 청구되지 않습니다.

| Code | Status | What to do |
|---|---|---|
| `output_schema_invalid` | 400 | 스키마가 지원 부분집합 밖입니다. `details.violations[]`가 각 문제를 가리킵니다. |
| `invalid_request` | 400 | `output_schema`와 `response_format`을 둘 다 보내는 경우를 포함합니다. |

## 체크리스트

- [ ] 루트는 정확히 `{"type": "object"}`입니다. `$defs`를 포함해 **모든** object에 `additionalProperties: false`입니다.
- [ ] 모든 노드에 `type`·`$ref`·`anyOf` 중 하나가 있고, 모든 `array`가 `items`를 선언합니다.
- [ ] 선언한 모든 속성이 `required`에 있고, 선택성은 nullable union입니다.
- [ ] `oneOf`, `allOf`, `not`, `if`/`then`/`else`, `nullable: true`가 없습니다.
- [ ] `$ref` 대상은 (루트에 선언한) `#/$defs/*`와 `SumeMediaFile#`뿐이며, `#`은 아닙니다.
- [ ] 스키마는 Format이 실제로 만드는 미디어만 요구합니다.
- [ ] `input`으로 보낸 값을 기대하는 필드가 없습니다 — projection은 그것을 보지 못합니다.
- [ ] `name`은 네임스페이스가 있고 안정적이어서 receipt를 검색하기 쉽습니다.
- [ ] `primary_output_key`는 UI가 보여 줄 하나를 가리킵니다.
- [ ] 리더는 `failed` run과 `completed` run 모두에서 `output_error`가 설정된 `output: null`을 처리합니다.
- [ ] 리더는 `output`이 null일 때 `artifacts[]`로 폴백합니다.

## 다음

- [`input` — 호출자 데이터](/formats/call#input-caller-data) — run 본문의 다른 절반이자, 스키마가 없는 쪽
- [실행과 결과](/formats/runs) — 이 모든 것을 담는 receipt
- [Format 호출하기](/formats/call) — invoke 계약
- [제품에 Format 임베드하기](/cookbooks/embed-a-format) — 출력을 자체 레코드에 매핑하기
