- name
- upload-ad-sample
- description
- Upload a finished Meta or video ad creative (image or video) to the Goose Ads sample library and fill its remix payload — recipe, script, render variant, and (for video formats like iMessage) the source conversation thread — so the sample is remixable in the app. Use after a creative is finalized and approved for showcasing on the public ads pages.
- tags
- ["ads","marketing","internal"]
# Upload Ad Sample
Publish a finished ad creative (video or image) into the Goose Ads sample
library, and — when the upload comes from a real ad build — fill the **remix
payload** so the sample is remixable in the app, not just a static showcase.
This is the canonical way to add new samples; the admin panel uses the same
endpoints under the hood.
**This is an agent-executed skill.** No external script. The agent does the
upload and registration inline using curl (or any HTTP client available in its
environment).
**Access:** the write endpoints require an API token whose owner is on the
admin allowlist (or an admin session). A regular user/API token is rejected with
403. If you get a 403, the token isn't admin-allowlisted — tell the user.
## When to use
- A creative video or image has just finished and is approved for public
showcasing on the marketing library page.
- You want a sample to also feature on the homepage ads grid — set
`is_featured: true`.
- **You want the sample to be remixable.** Two remix shapes exist:
- **Video formats (iMessage, etc.)** — pass `format_key` + `thread_json` so
the format tab can show the sample AND the remix recipe can run it. See
[Filling the video remix payload](#filling-the-video-remix-payload-imessage-and-other-video-formats).
- **Meta UGC ads from a content-goose project** — pass the project's
production JSON so worlds/characters/voices are parsed into `remix_spec`.
See [Filling the remix payload from a content-goose project](#filling-the-remix-payload-from-a-content-goose-project).
## Inputs
| Field | Required | Default | Notes |
|---|---|---|---|
| `title` | yes | — | Short headline, e.g. "Friend-asks-friend SPF reveal". |
| `format` | yes | — | `video` or `image`. |
| `ratio` | yes | — | `9:16`, `1:1`, `4:5`, or `16:9`. |
| `file_path` | yes (or `media_url`) | — | Local path to the video/image file. |
| `media_url` | yes (or `file_path`) | — | Public https URL of an already-hosted file. |
| `thumbnail_url` | no | — | Poster image URL. **Required for video** — auto-generated from a poster frame if absent (see below). |
| `brand` | no | — | Brand name shown on the card, e.g. "Clinikally". |
| `tags` | no | `[]` | Free-form tags array, e.g. `["imessage", "ugc"]`. |
| `is_published` | no | `false` | When `true` the sample is publicly visible immediately. Otherwise it's a draft, only visible in the admin panel. |
| `is_featured` | no | `false` | When `true` the sample also appears on the homepage ads grid (subject to ordering). |
| `is_remixable` | no | auto | When `true` the sample appears in the app's **create / remix picker** — INDEPENDENT of `is_published` (which gates the landing-page showcase). Defaults to `true` when a `recipe` is provided, else `false`. Set it explicitly to keep the remixable set separate from the showcase set (`is_published:true, is_remixable:false` = showcase-only; `is_published:false, is_remixable:true` = remixable but not on the landing page). |
| `sort_order` | no | `0` | Higher numbers appear earlier inside the featured grid. |
| `slug` | no | auto | Readable handle (e.g. `clinikally-imessage-spf`). The server auto-generates one from brand + title if omitted. Lowercase, hyphenated. |
| **`format_key`** | no | — | **Ad-format key for video formats**, e.g. `imessage`. Set this when the sample should be remixable through a format tab. Lowercase, hyphenated. |
| **`thread_json`** | no | — | **Path to the build's conversation thread JSON** (the file the renderer used, e.g. the build's `threads/full-thread.json`). Required to build a video `recipe`. |
| **`render_variant`** | no | `iphone-frame` (imessage) | **How the source was rendered:** `iphone-frame` (phone bezel) or `plain` (full-screen chat). The remix keeps the source's variant, so record it truthfully. |
| **`duration_sec`** | no | ffprobe | **Master video length in seconds.** Stored in `metadata`. If omitted, derive it with ffprobe on the master. |
| `recipe` | no | — | Built automatically from `format_key` + `thread_json` for video; a shot-list JSON object for other formats. |
| `extracted_script` | no | — | The source ad's script (plain text). Built automatically from the thread for video. |
| `skills_used` | no | `[]` | Flat atom slug list. **Atom slugs only — never a molecule** (see note below). Superseded by `remix_spec.skills` for UGC ads — prefer `remix_spec`. |
| `how_to` | no | — | Agent-facing production notes (the project's how-to). |
| `remix_spec` | no | — | Structured `{ version, skills[], worlds[], characters[], voices[] }` for Meta UGC ads. Build it with the parse endpoint below. |
> **`skills` and `skills_used` are ATOM slugs, never a molecule.** Record the
> atomic capability skills the ad actually used (`generate-voiceover`,
> `generate-broll-shot`, `compose-master`, …) — not the parent molecule that
> orchestrated them. A molecule slug is ambiguous on the remix side: it implies
> "run every sub-skill inside it," which over-specifies the recipe. The parse
> endpoint already returns atoms; if you set `skills_used` by hand, keep it
> atom-level too.
If both `file_path` and `media_url` are provided, prefer `file_path` (we
re-host the file under our own bucket).
**Fill the remix payload whenever the sample comes from a real ad build** — for
video formats that means `format_key`, `recipe`, and `extracted_script`; for
Meta UGC ads it means `recipe`, `extracted_script`, `skills_used`, `how_to`,
and `remix_spec`. Omit them only for a bare showcase upload that won't be
remixed.
## Environment
The skill resolves the API base from the first of:
1. `$GOOSEWORKS_API_BASE_URL`
2. `$GOOSEWORKS_API_URL`
3. Default: `https://app.gooseworks.ai`
Auth uses `$GOOSEWORKS_API_TOKEN` as a Bearer token (it must belong to an
admin-allowlisted user — see Access above).
If neither env var is set, ask the user where the API lives before proceeding.
## How to run
The flow is **3 HTTP calls** when uploading a local file, or **1 call** when
the asset is already hosted. For video, add a small thumbnail upload (below).
### Flow A — local file
#### 1. Request a presigned PUT URL
```bash
curl -sS -X POST "$API_BASE/api/ads-library/samples/upload-url" \
-H "Authorization: Bearer $GOOSEWORKS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filename": "<basename of file_path>",
"content_type": "<derived MIME>",
"kind": "media"
}'
```
Derive the MIME from the file extension:
| Extension | content_type |
|---|---|
| `.mp4` | `video/mp4` |
| `.mov` | `video/quicktime` |
| `.webm` | `video/webm` |
| `.png` | `image/png` |
| `.jpg`, `.jpeg` | `image/jpeg` |
| `.webp` | `image/webp` |
| `.gif` | `image/gif` |
Response:
```json
{
"status": "success",
"data": {
"upload_url": "https://<bucket>.s3.<region>.amazonaws.com/...",
"public_url": "https://<bucket>.s3.<region>.amazonaws.com/ads/samples/<uuid>/media-<filename>",
"key": "ads/samples/<uuid>/media-<filename>",
"expires_in_seconds": 900,
"required_headers": {
"content-type": "<MIME>",
"cache-control": "public, max-age=31536000, immutable"
}
}
}
```
The presigned URL expires in 15 minutes — do step 2 promptly.
#### 2. PUT the file bytes to S3
**Send every header in `required_headers` from step 1 exactly.** The presigner
signs both `content-type` AND `cache-control` into the signature, so a PUT that
omits the `Cache-Control` header is rejected by S3 with `SignatureDoesNotMatch`
(403). Mirror the response's `required_headers`:
```bash
curl -sS -X PUT "<upload_url>" \
-H "Content-Type: <MIME>" \
-H "Cache-Control: public, max-age=31536000, immutable" \
--data-binary "@<file_path>"
```
A `200` response means the file is uploaded.
#### 3. Register the sample
```bash
curl -sS -X POST "$API_BASE/api/ads-library/samples" \
-H "Authorization: Bearer $GOOSEWORKS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "<title>",
"format": "<format>",
"ratio": "<ratio>",
"media_url": "<public_url from step 1>",
"thumbnail_url": "<required for video — see Thumbnails>",
"brand": "<optional>",
"tags": ["..."],
"is_published": false,
"is_featured": false,
"slug": "<optional readable handle>",
"format_key": "<optional, e.g. imessage>",
"recipe": <optional recipe object — see the remix sections>,
"extracted_script": "<optional script>",
"metadata": <optional object, e.g. { "duration_sec": 24 }>,
"skills_used": ["..."],
"how_to": "<optional production notes>",
"remix_spec": <optional UGC remix_spec from the parse endpoint>
}'
```
A `201` response with `data.id` confirms the sample was registered. The remix
fields are all optional and nullable — include whatever you have. Assemble the
**full** body and POST it **once** (see the one-POST rule below); don't create a
bare sample and PATCH the remix fields in afterwards.
### Flow B — already-hosted media
Skip steps 1 and 2. Call step 3 directly with `media_url` set to the existing
public URL. (Video still needs a `thumbnail_url`.)
### Fallback if step 1/2 hit CORS or the bucket isn't public-PUT-enabled
There's a server-side proxy upload that streams through the API:
```bash
curl -sS -X POST "$API_BASE/api/ads-library/samples/upload" \
-H "Authorization: Bearer $GOOSEWORKS_API_TOKEN" \
-F "file=@<file_path>" \
-F "kind=media"
```
Returns `{ data: { public_url, key, content_type, size_bytes } }`. Use the
`public_url` in step 3. Prefer the presigned flow when it works — this proxy
limits files to 250 MB and pushes bytes through Express memory.
### Thumbnails (required for video)
Every **video** sample needs a `thumbnail_url` — the library and format-tab
cards render a poster, and a video with no thumbnail shows a blank tile. If the
caller didn't supply one, grab a poster frame from the master and upload it as a
thumbnail:
```bash
# 1. Extract a poster frame (1s in) from the master
ffmpeg -y -ss 1 -i "<file_path>" -frames:v 1 -q:v 2 "<tmp>/poster.jpg"
# 2. Presign with kind: "thumbnail" (thumbnails must be an image MIME)
curl -sS -X POST "$API_BASE/api/ads-library/samples/upload-url" \
-H "Authorization: Bearer $GOOSEWORKS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "filename": "poster.jpg", "content_type": "image/jpeg", "kind": "thumbnail" }'
# 3. PUT the poster with BOTH required headers (content-type + cache-control)
curl -sS -X PUT "<thumbnail upload_url>" \
-H "Content-Type: image/jpeg" \
-H "Cache-Control: public, max-age=31536000, immutable" \
--data-binary "@<tmp>/poster.jpg"
```
Use the thumbnail's `public_url` as `thumbnail_url` in the step-3 register call.
Images don't need this — only video.
## Filling the video remix payload (iMessage and other video formats)
When the sample is a video built for a format tab (iMessage today), pass
`format_key` and `thread_json` so the app can both **show** it under the format
tab and **remix** it. The remix recipe reads `recipe.thread`,
`recipe.render_variant`, and `extracted_script` to rebuild the conversation for
a new brand, so build them exactly as below.
### 1. Build the `recipe`
Read and parse the thread JSON file at `thread_json` (the conversation the
renderer used — typically the build's `threads/full-thread.json`). Then build:
```jsonc
{
"format": "<format_key>", // e.g. "imessage"
"render_variant": "<iphone-frame|plain>", // how this source was rendered; default "iphone-frame" for imessage
"thread": <the parsed thread JSON, verbatim>
}
```
`recipe.thread` is stored **verbatim** — keep whatever the build emitted. The
iMessage thread schema is the conversation the renderer drives: a top-level
object with `participants[]` (each `{ id, name, self? }`) and an ordered
`messages[]` array of `{ id, type: "text"|"typing"|"attachment", from, text?,
src?, … }`. Some older builds use a flatter `{ peer_persona, bubbles[],
end_card }` shape — either is fine; store what the build produced. Don't
hand-rewrite or trim it.
`recipe` must be a JSON **object** and serialize to ≤ 64 KB. A normal iMessage
thread is a couple KB; if a thread is unusually large (many inline data-URL
attachments), host the attachments and reference them by URL rather than
inlining the bytes.
Auf GitHub ansehen