# Zebrafish Cookbook

Recipes that work well on Zebrafish. Two kitchens: **humans** briefing the
agent at `/agent` and working in Studio, and **AI agents** driving the v1 API.

Every recipe is grounded in real step types, real model IDs, and real
endpoints. If a recipe stops working, the cause is almost always one of the
gotchas in [The Pantry](#the-pantry) at the bottom — read it first if you're
debugging.

## How to read a recipe

```
GOAL        what you get out the other end
KITCHEN     human (Studio or /agent) or agent (v1 API)
INGREDIENTS step types / models / endpoints used
RECIPE      the steps, copy-pasteable
WHY IT WORKS the one non-obvious thing that makes it land
```

A workflow step is always this shape:

```jsonc
{
  "id": "hero_image",        // referenced by other steps as hero_image.output.*
  "name": "Hero image",      // human label
  "type": "generate_image",  // one of the 18 step types
  "dependsOn": [],           // step IDs whose output this step reads
  "config": { /* ... */ }    // per-type inputs
}
```

Templating (this is the part everyone gets wrong — note **singular `input`**):

- `{{ input.product_brief }}` — a workflow input value at run time
- `{{ hero_image.output.url }}` — an upstream step's output (the step MUST be in
  `dependsOn`)

## Part 1 — Human recipes (Studio + /agent)

For workflow authoring, brief the agent at `/agent`; it creates, edits,
validates, test-runs, and publishes workflows after approval. Use `/workflows`
to inspect definitions, run statuses, costs, approvals, outputs, and failures.

### R1. Variant shotgun → promote the winner

```
GOAL        4 takes on one creative idea, keep the best as a reusable step
KITCHEN     human · Studio
INGREDIENTS Studio batch generate · openai/gpt-image-2 · promote-to-workflow
```

**Recipe**

1. Open Studio. Pick `openai/gpt-image-2` (default high-quality T2I).
2. Write one strong prompt. Set **variants = 4**.
3. Generate. Four images come back with their own status + cost each.
4. Click the winner → **Promote to workflow** (new workflow, or append to a
   draft).

**Why it works** — Studio batches are the cheapest way to explore a prompt's
range before you commit it to a workflow. Promotion carries the exact model +
prompt + config across, so the step you ship is the one you actually picked,
not a re-typed approximation.

### R2. Hero image → animated teaser

```
GOAL        a generated still that turns into a 5s motion clip
KITCHEN     human · /agent
INGREDIENTS generate_image → generate_video · Seedance 2.0 i2v
```

**Recipe** — brief the agent at `/agent`: "Build a workflow with a hero image
for {product}, then animate it with subtle camera motion." You should get:

```jsonc
[
  {
    "id": "hero_image", "name": "Hero image", "type": "generate_image",
    "dependsOn": [],
    "config": {
      "model": "openai/gpt-image-2",
      "prompt": "Polished hero image for {{ input.product_brief }}",
      "aspect_ratio": "16:9"
    }
  },
  {
    "id": "teaser", "name": "Teaser video", "type": "generate_video",
    "dependsOn": ["hero_image"],
    "config": {
      "model": "bytedance/seedance-2.0/image-to-video",
      "image_url": "{{ hero_image.output.url }}",
      "prompt": "Subtle parallax camera push-in",
      "duration": "5"
    }
  }
]
```

**Why it works** — the video step's `image_url` binds to
`{{ hero_image.output.url }}` and lists `hero_image` in `dependsOn`. This is the
**media handoff** rule. If the video step instead points at
`{{ input.image_url }}`, the run animates nothing (or a dangling input) — the
single most common broken-workflow shape. Verify the binding before you approve
a test run.

### R3. Project-locked brand creative

```
GOAL        every asset on-brief, on-tone, automatically
KITCHEN     human · Project + /agent
INGREDIENTS Project brief + files · agent project adherence · {{ brief }}
```

**Recipe**

1. Create a Project (org admin only — both paths need `org:admin`). Either
   click **Set up with Agent** on `/campaigns` and hand it a website +
   instructions — it creates/binds the Project, then researches and writes the
   brief/knowledge base once the Agent session opens and runs the seeded first
   turn — or use **Quick blank project** to write the brief (voice, palette,
   do/don'ts) and upload brand files (logos, product shots, decks) yourself.
2. Pick the project from the **Project** selector next to the composer on
   `/agent` or in Studio — same shared control on both surfaces. In Studio it
   never touches your typed prompt. In Agent, switching projects mid-
   conversation starts a fresh session (project scope is locked once a
   conversation has turns), so send or discard your draft first.
3. Brief the agent at `/agent`. Each turn gets a per-turn project digest
   appended to your message, marked as untrusted project data rather than
   instructions; do/don't items still shape prompt, step, and model choices.
   If the context bridge is unreachable, the turn degrades to running on your
   raw text instead of failing.
4. Use `{{ brief }}` inside a Studio prompt to expand the full brief text at
   generate time (the Agent equivalent is the `{{ campaign.brief }}` token).

**Why it works** — the digest rides along on every agent turn, so you stop
re-pasting "remember, no blue, friendly tone" into each prompt. Run snapshots
capture the brief + files at trigger time, and Studio generations record a
receipt of exactly which project revision/knowledge/assets fed the prompt, so
output stays reproducible even after the brief changes.

### R4. Figma frame → on-brand social variants

```
GOAL        pull a designed frame out of Figma and remix it
KITCHEN     human · /agent
INGREDIENTS figma_export → generate_image (edit) · GPT Image 2 Edit
```

**Recipe** — brief the agent at `/agent` to export a Figma frame and remix it
into social variants. It should produce a definition shaped like:

```jsonc
[
  {
    "id": "frame", "name": "Export Figma frame", "type": "figma_export",
    "dependsOn": [],
    "config": { "url": "{{ input.figma_hero_url }}", "format": "png", "scale": 2 }
  },
  {
    "id": "remix", "name": "Social remix", "type": "generate_image",
    "dependsOn": ["frame"],
    "config": {
      "model": "openai/gpt-image-2/edit",
      "prompt": "Reframe for a 1:1 Instagram post, keep the logo and headline",
      "image_url": "{{ frame.output.url }}"
    }
  }
]
```

**Why it works** — `figma_export` rehosts Figma's ephemeral S3 URL to a permanent
R2 URL, so the downstream edit step has a stable, allowlisted source. (Figma's
own URL would fail the SSRF allowlist; the export step is the bridge.) Use
`figma_read` instead when you only need the text/structure, not the pixels.

### R5. Voiceover ad (image + motion + narration, stitched)

```
GOAL        a narrated video ad from a brief + a script
KITCHEN     human · /agent
INGREDIENTS generate_image → generate_video · generate_audio · media_concat/caption
```

**Recipe** — brief the agent at `/agent` to build a narrated video workflow.
Three producers feed one assembler:

```jsonc
[
  { "id": "scene", "type": "generate_image", "name": "Scene",
    "dependsOn": [],
    "config": { "model": "openai/gpt-image-2",
                "prompt": "{{ input.product_brief }}", "aspect_ratio": "16:9" } },

  { "id": "motion", "type": "generate_video", "name": "Motion",
    "dependsOn": ["scene"],
    "config": { "model": "bytedance/seedance-2.0/image-to-video",
                "image_url": "{{ scene.output.url }}", "duration": "5" } },

  { "id": "vo", "type": "generate_audio", "name": "Voiceover",
    "dependsOn": [],
    "config": { "model": "fal-ai/elevenlabs/tts/multilingual-v2",
                "text": "{{ input.voiceover_script }}" } }
]
```

Then assemble — burn captions with `media_caption`, or for audio-over-video use
`media_*` processing steps to combine. For narration, write into `config.text`
(not `config.prompt`) — mixing both is a validation error.

**Why it works** — image and audio have no dependency on each other, so they run
in parallel; only `motion` waits on `scene`. Seedance 2.0 can itself produce
audio-capable cinematic video if you'd rather skip the separate TTS track.

### R6. Spend gate before the expensive step

```
GOAL        a human signs off before any pricey video/render runs
KITCHEN     human · /agent
INGREDIENTS generate_image → approval → generate_video
```

**Recipe** — brief the agent at `/agent` to put an approval gate between the
cheap draft and the expensive render:

```jsonc
[
  { "id": "draft_still", "type": "generate_image", "name": "Draft still",
    "dependsOn": [], "config": { "model": "openai/gpt-image-2",
    "prompt": "{{ input.product_brief }}" } },

  { "id": "gate", "type": "approval", "name": "Approve before render",
    "dependsOn": ["draft_still"],
    "config": { "showFields": ["draft_still.output.url"], "timeout": "24h" } },

  { "id": "render", "type": "generate_video", "name": "Final render",
    "dependsOn": ["gate", "draft_still"],
    "config": { "model": "fal-ai/kling-video/v3/pro/image-to-video",
                "image_url": "{{ draft_still.output.url }}" } }
]
```

**Why it works** — the run pauses at `gate` and shows the cheap still. A human
approves (or rejects with feedback) before the premium Kling render spends real
money. `showFields` surfaces exactly the upstream output the approver needs to
decide. FAL bills on submission with no refund, so the gate goes *before* the
expensive call, never after.

### R7. Clean cutout → composite

```
GOAL        product shot on a transparent/new background
KITCHEN     human · Studio or /agent
INGREDIENTS Bria Background Remove → GPT Image 2 Edit
```

**Recipe** — in Studio, run `fal-ai/bria/background/remove` on the product
photo (commercial-safe), then feed the cutout as a reference image into
`openai/gpt-image-2/edit` (up to 16 references) to drop it into a new scene.
For a repeatable workflow, brief the agent at `/agent` to put the Bria removal
step before the GPT Image 2 Edit step.

**Why it works** — Bria's removal is clean enough to use as an edit reference,
and GPT Image 2 Edit composites references convincingly. In Studio, uploading a
reference image auto-flips a text-to-image model to its image-to-image mode.

## Part 2 — AI agent recipes (v1 API)

Base URL: `https://zebrafish.dev/api/v1` (env-driven via `NEXT_PUBLIC_APP_URL`).
Auth: `Authorization: Bearer adf_...`. Spec: `GET /api/v1/openapi.json`
(public). Full reference: [API Keys](/docs/api-keys).

### R8. The proof path: author → validate → test → publish

```
GOAL        an agent builds a correct workflow and proves it before publishing
KITCHEN     agent · v1 API
SCOPES      workflows:write, runs:trigger, workflows:publish
```

**Recipe**

1. `GET /workflow-authoring-guide` — versioned step catalog (single source of
   truth for shapes; don't guess from memory).
2. `POST /workflows/from-template` `{ templateId, name }` + `Idempotency-Key`
   → `wf_…`, `rev_…`.
3. `GET /workflows/:id/authoring-context` → latest revision id, campaign,
   current definition.
4. `POST /workflows/:id/draft/validate` `{ inputs, steps }` → loop on
   `diagnostics` until `{ ok: true }`.
5. `POST /workflows/:id/draft-revisions` `{ basedOnRevisionId, inputs, steps }`
   + `Idempotency-Key` → new `rev_…`.
6. `POST /workflows/:id/draft-test-runs` `{ revision, inputs }` → run it once.
7. Poll `GET /runs/:runId/events` until terminal (see R10).
8. `POST /workflows/:id/revisions/:revId/publish`
   `{ expectedLatestRevisionId }` + `Idempotency-Key`.

**Why it works** — validate-before-save means you never persist a broken draft;
test-before-publish means you never publish an untested one. `basedOnRevisionId`
+ `expectedLatestRevisionId` give optimistic concurrency: if another actor saved
in between you get `409 stale_revision` — re-fetch context, re-apply, retry with
a fresh key. This mirrors the build-validate-fix loop the `/agent` page runs for
humans, exposed for agents.

### R9. Fire-and-poll a published workflow (idempotent)

```
GOAL        trigger a run safely, even with network retries
KITCHEN     agent · v1 API
SCOPES      runs:trigger, workflows:read
```

```typescript
import { randomUUID } from "crypto";

const key = randomUUID(); // reuse this exact key on retries
const res = await fetch(`${BASE}/workflows/${wf}/runs`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ADFISH_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": key,
  },
  body: JSON.stringify({ inputs: { product_brief: "Spring launch" } }),
});
const { id: runId } = await res.json();
```

**Why it works** — the `Idempotency-Key` is replayed for 48h: a retried POST
returns the *same* run instead of starting a second (paid) one. Reuse the key
verbatim on retry; a new key means a new run. Pass `campaignId` in the body to
run with campaign context.

### R10. Poll a run to completion (the right way)

```
GOAL        know exactly when a run is done, with no dropped events
KITCHEN     agent · v1 API
SCOPES      workflows:read
```

```bash
after=""
while :; do
  url="$BASE/runs/$RUN_ID/events?limit=50${after:+&after=$after}"
  r=$(curl -s "$url" -H "Authorization: Bearer $ADFISH_API_KEY")
  status=$(jq -r .status <<<"$r")
  echo "$r" | jq -c '.data[] | {stepId, eventType}'
  after=$(jq -r '.pagination.after // empty' <<<"$r")
  more=$(jq -r '.pagination.hasMore' <<<"$r")
  case "$status" in completed|failed|cancelled|dispatch_failed)
    [ "$more" = false ] && break ;; esac
  sleep 1
done
```

**Why it works** — you exit only when status is terminal **and**
`hasMore=false`. Checking status alone races against undelivered events and
truncates the tail. Carry `after` forward so each poll fetches only new events.

### R11. Studio compare batch via API

```
GOAL        generate N variants headless and pick programmatically
KITCHEN     agent · v1 API
SCOPES      studio:write, studio:read
```

```bash
curl -X POST $BASE/studio/generations \
  -H "Authorization: Bearer $ADFISH_API_KEY" -H "Content-Type: application/json" \
  -d '{ "modelId": "openai/gpt-image-2",
        "config": { "input": { "prompt": "A lighthouse at dawn" } },
        "idempotencyKey": "lighthouse-001",
        "variants": 3 }'
# → { "batchId": "batch_…", "items": [ { "id": "sg_1", ... }, ... ] }
```

Then poll each `GET /studio/generations/:id` until `status` is `succeeded`
(`outputUrl` populated) or `failed`/`timed_out`.

**Why it works** — `variants` (2–4) returns a batch you can score by your own
metric (CLIP, a judge model, brand rules) and keep the winner. Note the
idempotency key lives in the **body** here, not the header. Video generations
can take 10+ minutes — back off your poll interval accordingly.

### R12. Campaign-as-context automation (upload + run)

```
GOAL        seed a campaign with assets, then run workflows against it
KITCHEN     agent · v1 API
SCOPES      campaigns:write, assets:write, runs:trigger
```

**Recipe**

1. `POST /campaigns` `{ name, brief }` + `Idempotency-Key` → `cmp_…`.
2. `POST /campaigns/:id/assets/presign` `{ filename, mimeType, sizeBytes }`
   → short-lived R2 PUT url. (MCP: `create_campaign_asset_upload`)
3. `PUT` the bytes to `uploadUrl` (15-min expiry).
4. `POST /campaigns/:id/assets/finalize` `{ assetId, r2Key }` → asset active.
   (MCP: `finalize_campaign_asset_upload`)
5. `POST /campaigns/:id/workflows` `{ workflowIds }` to attach.
   (MCP: `bulk_attach_campaign_workflows`)
6. Trigger with `{ inputs, campaignId }` (R9). Run snapshot captures brief +
   files at trigger time.

**Why it works** — presign → upload → finalize keeps large files off the API
server (direct-to-R2) and avoids half-uploaded assets ever going active. The
project brief then flows into every run as reproducible context, the API
mirror of recipe R3.

### R13. Cost guardrails around every run

```
GOAL        never blow the budget; attribute spend per step
KITCHEN     agent · v1 API
SCOPES      admin (org spend), usage:read (run costs)
```

**Recipe**

- **Before:** `GET /orgs/me?expand=reservations` → check `monthToDate.percentUsed`
  and remaining headroom under `spendCap`. Skip/defer if near the cap.
- **After:** `GET /runs/:runId/costs` → per-step provider, model, tokens,
  duration, `chargeStatus`, and a run total in USD micros.
- **Trends:** `GET /usage?from=…&to=…&provider=…` → daily roll-up by provider.

**Why it works** — FAL bills on submission with no refund, so the only real
guardrail is checking headroom *before* you trigger. Costs serialize as micros
(string) to dodge JSON float overflow — divide by 1e6 for dollars. A `402
spend_cap_exceeded` is retryable only after the cap is raised.

### R14. Register a new FAL model, then use it

```
GOAL        bring a FAL endpoint Zebrafish doesn't curate into Studio/workflows
KITCHEN     agent · v1 API
SCOPES      models:write, studio:write
```

**Recipe**

1. `POST /models` `{ endpointId, billingUnit? }` → derives a profile +
   creates an **unverified** model row.
2. `GET /models` to confirm it (and see its derived constraints/pricing).
3. Use the `modelId` in `studio/generations` or in a `fal_run` step.

**Why it works** — `fal_run` is the generic escape hatch: any FAL queue model
with an explicit `input` payload and a `media_url_path`/`result_mode` selector to
extract the output. Registration just makes the model show up in the catalog with
constraints; `fal_run` works even without it.

## The Pantry

The handful of facts that make or break every recipe.

### Template syntax (singular!)

| Want | Write |
|------|-------|
| A workflow input | `{{ input.product_brief }}` |
| An upstream step output | `{{ hero_image.output.url }}` |
| Project brief text | `{{ brief }}` |

It's `input`, not `inputs`. The step you reference in a `{{ x.output.* }}`
template MUST be listed in that step's `dependsOn`, or the value is undefined at
run time.

### Model cheat sheet (23 curated FAL profiles)

| Job | Reach for |
|-----|-----------|
| T2I, high quality | `openai/gpt-image-2` |
| T2I, fast | `fal-ai/nano-banana-2` |
| T2I, premium | `fal-ai/flux-2-pro` · `fal-ai/bytedance/seedream/v4.5/text-to-image` |
| Image edit (refs) | `openai/gpt-image-2/edit` (≤16) · `fal-ai/nano-banana-2/edit` (≤14) |
| Background removal | `fal-ai/bria/background/remove` |
| Upscale | `fal-ai/seedvr/upscale/image` |
| Image → video | `bytedance/seedance-2.0/image-to-video` · `fal-ai/kling-video/v3/pro/image-to-video` |
| Text → video | `bytedance/seedance-2.0/text-to-video` · `fal-ai/kling-video/v3/pro/text-to-video` |
| Multi-ref → video | `bytedance/seedance-2.0/reference-to-video` (≤9 refs) |
| Voiceover / TTS | `fal-ai/elevenlabs/tts/multilingual-v2` · `…/eleven-v3` |
| Anything else on FAL | `fal_run` step / `POST /models` to register |

### Step types (18)

- **Media gen:** `generate_image`, `generate_video`, `generate_audio`, `fal_run`
- **Media processing (FFmpeg):** `media_thumbnail`, `media_transcode`,
  `media_trim`, `media_crop_resize`, `media_concat`, `media_caption`
- **Library:** `search_library`, `bridge_to_library`
- **Design:** `figma_read`, `figma_export`
- **Channels:** `post_channel_message`
- **Control flow:** `agent`, `approval`, `conditional`

### Gotchas that bite

- **Media handoff** — i2v/animation steps must `dependsOn` the image step and
  bind `image_url` to its `…output.url`, never to a raw `{{ input.image_url }}`.
  (R2)
- **FAL bills on submission, no refund** — gate expensive steps behind an
  `approval` (humans) or a spend check (agents) *before* the call. (R6, R13)
- **SSRF allowlist is fail-closed** — only `*.fal.media`, `*.fal.ai`, and the
  R2 host are reachable. Figma pixels must go through `figma_export` to be
  rehosted to R2. New step types with URL fields need an allowlist entry
  (`trigger/src/lib/url-allowlist.ts`). (R4)
- **Audio narration** — write into `config.text`, not `config.prompt`. Setting
  both is a validation error. (R5)
- **Idempotency placement** — header (`Idempotency-Key`) for most writes; body
  field (`idempotencyKey`) for studio generations. Reuse the exact value on
  retry. (R9, R11)
- **Costs are micros strings** — divide by 1e6 for USD; don't parse as float
  before dividing. (R13)
- **Poll until terminal AND `hasMore=false`** — status alone truncates the
  event tail. (R10)

---

*Sources: `shared/steps/catalog.ts`, `shared/fal/profiles.ts`,
`trigger/src/lib/url-allowlist.ts`, `src/lib/mcp-tools.ts`,
`docs/api-keys.md`, `src/app/api/v1/`. Keep this file honest — if a recipe
breaks, fix the recipe, don't paper over it.*
