Setup guideAPI setup
0/4
  1. Choose API pathAPI is the route.
  2. Copy OpenAPI pathFirst machine contract is ready.
  3. Read API keysScopes and bearer auth are clear.
  4. Ready to runYou have enough context to call v1.
Get startedZebrafish Cookbook

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.

Raw

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 at the bottom — read it first if you're debugging.

How to read a recipe#

Snippet
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#

Snippet
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#

Snippet
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#

Snippet
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#

Snippet
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 worksfigma_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)#

Snippet
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#

Snippet
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#

Snippet
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.

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

Snippet
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-Keywf_…, 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)#

Snippet
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)#

Snippet
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#

Snippet
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 worksvariants (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)#

Snippet
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-Keycmp_….

  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#

Snippet
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#

Snippet
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 worksfal_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!)#

WantWrite
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)#

JobReach for
T2I, high qualityopenai/gpt-image-2
T2I, fastfal-ai/nano-banana-2
T2I, premiumfal-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 removalfal-ai/bria/background/remove
Upscalefal-ai/seedvr/upscale/image
Image → videobytedance/seedance-2.0/image-to-video · fal-ai/kling-video/v3/pro/image-to-video
Text → videobytedance/seedance-2.0/text-to-video · fal-ai/kling-video/v3/pro/text-to-video
Multi-ref → videobytedance/seedance-2.0/reference-to-video (≤9 refs)
Voiceover / TTSfal-ai/elevenlabs/tts/multilingual-v2 · …/eleven-v3
Anything else on FALfal_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.