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 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 landA workflow step is always this shape:
{
"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 independsOn)
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-workflowRecipe
Open Studio. Pick
openai/gpt-image-2(default high-quality T2I).Write one strong prompt. Set variants = 4.
Generate. Four images come back with their own status + cost each.
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 i2vRecipe — brief the agent at /agent: "Build a workflow with a hero image
for {product}, then animate it with subtle camera motion." You should get:
[
{
"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
Create a Project (org admin only — both paths need
org:admin). Either click Set up with Agent on/campaignsand 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.Pick the project from the Project selector next to the composer on
/agentor 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.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.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 EditRecipe — brief the agent at /agent to export a Figma frame and remix it
into social variants. It should produce a definition shaped like:
[
{
"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/captionRecipe — brief the agent at /agent to build a narrated video workflow.
Three producers feed one assembler:
[
{ "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_videoRecipe — brief the agent at /agent to put an approval gate between the
cheap draft and the expensive render:
[
{ "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 EditRecipe — 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#
GOAL an agent builds a correct workflow and proves it before publishing
KITCHEN agent · v1 API
SCOPES workflows:write, runs:trigger, workflows:publishRecipe
GET /workflow-authoring-guide— versioned step catalog (single source of truth for shapes; don't guess from memory).POST /workflows/from-template{ templateId, name }+Idempotency-Key→wf_…,rev_….GET /workflows/:id/authoring-context→ latest revision id, campaign, current definition.POST /workflows/:id/draft/validate{ inputs, steps }→ loop ondiagnosticsuntil{ ok: true }.POST /workflows/:id/draft-revisions{ basedOnRevisionId, inputs, steps }Idempotency-Key→ newrev_….
POST /workflows/:id/draft-test-runs{ revision, inputs }→ run it once.Poll
GET /runs/:runId/eventsuntil terminal (see R10).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
expectedLatestRevisionIdgive optimistic concurrency: if another actor saved in between you get409 stale_revision— re-fetch context, re-apply, retry with a fresh key. This mirrors the build-validate-fix loop the/agentpage 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:readimport { 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:readafter=""
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
doneWhy 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:readcurl -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:triggerRecipe
POST /campaigns{ name, brief }+Idempotency-Key→cmp_….POST /campaigns/:id/assets/presign{ filename, mimeType, sizeBytes }→ short-lived R2 PUT url. (MCP:create_campaign_asset_upload)PUTthe bytes touploadUrl(15-min expiry).POST /campaigns/:id/assets/finalize{ assetId, r2Key }→ asset active. (MCP:finalize_campaign_asset_upload)POST /campaigns/:id/workflows{ workflowIds }to attach. (MCP:bulk_attach_campaign_workflows)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→ checkmonthToDate.percentUsedand remaining headroom underspendCap. 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:writeRecipe
POST /models{ endpointId, billingUnit? }→ derives a profile + creates an unverified model row.GET /modelsto confirm it (and see its derived constraints/pricing).Use the
modelIdinstudio/generationsor in afal_runstep.
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_runMedia processing (FFmpeg):
media_thumbnail,media_transcode,media_trim,media_crop_resize,media_concat,media_captionLibrary:
search_library,bridge_to_libraryDesign:
figma_read,figma_exportChannels:
post_channel_messageControl flow:
agent,approval,conditional
Gotchas that bite#
Media handoff — i2v/animation steps must
dependsOnthe image step and bindimage_urlto 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 throughfigma_exportto 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, notconfig.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.