Skill: FNF SDK

Use this when the task touches @higgsfield/fnf: generation jobs, media upload, profile/workspace data, adapters, observability, or server-side job submission.

Before coding, read:

Template Rules

Mandatory Generation Website Checklist

For prompts like "create a Nano Banana generation app", "build a Seedance form", "make an image/video generator", or anything equivalent, deliver all of this:

Omission is a bug. The ONLY exception is when the user EXPLICITLY asks for an offline/mock demo (memory adapters, no network) — never choose a mock as the default; the default is a real, end-to-end app with a real backend and D1.

Output and download format contract

Choose the app's public export behavior once and apply it to every submit and download path:

For protected app-local downloads inside the Higgsfield iframe, also follow references/auth.md → "Authenticated File Downloads". A direct raw link is valid only when it is public or a self-contained signed URL requiring no session.

Elements and custom-reference character training

Use these capabilities when an app needs reusable user assets or a consistent trained subject. Keep the concepts distinct:

All calls stay server-side after the Higgsfield auth guard and reuse the same createWorkflowPlatformAdapter({ baseUrl: 'https://fnf.internal' }):

import { createCharacterClient } from '@higgsfield/fnf/characters'
import { createJobClient } from '@higgsfield/fnf/client'
import { soulV2Image } from '@higgsfield/fnf/jobs'
import { createReferenceClient } from '@higgsfield/fnf/references'
import { createWorkflowPlatformAdapter } from '@higgsfield/fnf/workflow-platform'

const adapter = createWorkflowPlatformAdapter({
  baseUrl: 'https://fnf.internal',
  confirm: async () => confirmationToken,
})

const elements = createReferenceClient({ adapter })
const characters = createCharacterClient({ adapter })
const jobs = createJobClient({ adapter, jobs: [soulV2Image] })

const library = await elements.list({ category: 'character', size: 50 })

// images are MediaRef values returned by media.upload(...) or compatible
// image job refs. Upload browser Files with multipart FormData first.
const pending = await characters.create({
  name: 'My character',
  type: 'soul_2',
  images,
})
const character = await characters.wait(pending)
if (character.status === 'failed')
  throw new Error(character.failReason ?? 'Character training failed')

const result = await jobs.submit({
  model: 'text2image_soul_v2',
  prompt: { instruction: 'Candid flash photo at a late-night diner' },
  settings: {
    customReferenceId: character.id,
    aspectRatio: '3:4',
    batchSize: 4,
  },
})

Character contract:

A complete Elements-and-character flow includes auth/profile/credits, an existing Elements picker, multipart multi-image upload, create + poll character states, Elements refresh, Soul generation using customReferenceId, the normal confirmation/cost flow, generation polling, and history/result rendering.

Stateful realtime image editing and saved custom styles

Use the realtime client when an app needs successive image edits in one chain or user-level saved styles:

import {
  buildRealtimeChainEditRequest,
  createRealtimeClient,
  type RealtimeChainEditInput,
} from '@higgsfield/fnf/realtime'
import { createWorkflowPlatformAdapter } from '@higgsfield/fnf/workflow-platform'

const adapter = createWorkflowPlatformAdapter({ baseUrl: 'https://fnf.internal' })
const realtime = createRealtimeClient({ adapter, jobAdapter: adapter })

const input: RealtimeChainEditInput = {
  params: {
    prompt: 'Turn this into a candid editorial portrait',
    resolution: '1k',
    aspectRatio: '1:1',
    images, // explicit MediaRef/image-job refs; at most four
  },
}
const cost = await realtime.estimateChainCost({
  resolution: input.params.resolution,
  aspectRatio: input.params.aspectRatio,
})
// Browser: request approval for the exact validated wire.
const wire = buildRealtimeChainEditRequest(input)
const confirmationToken = await window.hf.requestGeneration(
  'flux_klein_realtime',
  wire,
  { credits: cost.credits },
)
// Authenticated server function: submit the same input with its opaque token.
const edit = await realtime.editChain(input, { confirmationToken })
const generation = await realtime.pollEditJob(edit.jobId)

// Continue by passing edit.chainId and a new explicit image set.
await realtime.finalizeChain({ chainId: edit.chainId })

The confirmation call runs in the browser through window.hf.requestGeneration; the client creation, edit, polling, finalize, and style calls stay behind authenticated server functions. Send the opaque token and the exact same input to the server; never log it. If one action launches several independent edits, build every wire request first and use the existing batch confirmation form, matching returned tokens by index. Every editChain call is billable and intentionally not retried.

Realtime contract:

Common Imports

import { createJobClient } from '@higgsfield/fnf/client'
import { createMediaClient } from '@higgsfield/fnf/media'
import { createProfileClient } from '@higgsfield/fnf/profile'
import { createWorkflowPlatformAdapter } from '@higgsfield/fnf/workflow-platform'
import { nanoBanana2, seedance2_0 } from '@higgsfield/fnf/jobs'

Server Pattern

Create the adapter inside server-only code after auth has been checked.

const auth = await requireCurrentUser()
if (!auth.ok) {
  return new Response(JSON.stringify(auth.body), {
    status: auth.status,
    headers: { 'content-type': 'application/json' },
  })
}

Then create SDK clients with the fnf.internal logical-operation adapter:

const FNF_INTERNAL_BASE_URL = 'https://fnf.internal'

const adapter = createWorkflowPlatformAdapter({
  baseUrl: FNF_INTERNAL_BASE_URL,
  observability,
})

const jobs = createJobClient({ adapter, jobs: [seedance2_0, nanoBanana2] })
const media = createMediaClient({ mediaAdapter: adapter })
const profile = createProfileClient({ profileAdapter: adapter })

Generated websites use fnf.internal only:

const adapter = createWorkflowPlatformAdapter({
  baseUrl: 'https://fnf.internal',
  observability,
})

Do not pass getToken. Do not send Authorization. The platform attaches identity automatically for server-side calls to https://fnf.internal.

This adapter sends SDK operations only under the fnf.internal logical route families /user, /workspaces/*, and /jobs/*. The platform behind fnf.internal decides final internal routing.

Do not add model-specific route logic in website code. The SDK builds validated wire params, fnf.internal resolves final endpoints, and the website handles only safe request/response state.

Generated websites must never use:

process.env.FNF_BASE_URL
const backendUrl = process.env.SOME_BACKEND_URL
createWorkflowPlatformAdapter({ baseUrl: backendUrl })
createFnfWebAdapter({ baseUrl: '...' })
createDevFnfWebAdapter(...)
createAppsMarketplaceAdapter(...)
fetch('https://fnf.internal/jobs') // hand-written request; use the SDK adapter

fnf.internal Method Contract

Generated websites must not decide fnf.internal HTTP methods themselves. Use SDK clients, and let createWorkflowPlatformAdapter send the correct operation:

SDK operation fnf.internal method/path
job submit POST /jobs/submit
job cost POST /jobs/cost
job cancel POST /jobs/{id}/cancel
media presign POST /jobs/media/presign
media confirm POST /jobs/media/{id}/confirm
job get GET /jobs/{id}
job set get GET /jobs/sets/{id}
job list/feed GET /jobs?gen_type=...&size=...
media get/list GET /jobs/media/{id} / GET /jobs/media?...
Element get/list GET /reference-elements/{id} / GET /reference-elements?...
character train/read POST /custom-references / GET /custom-references/{id}
realtime edit/cost/finalize POST /realtime/chain/edit / POST /realtime/chain/cost / POST /realtime/chain/finalize
saved style list/create GET /realtime/custom-styles / POST /realtime/custom-styles
saved style update/delete PATCH /realtime/custom-styles/{id} / DELETE /realtime/custom-styles/{id}
profile user GET /user
workspace list/current/wallet GET /workspaces, /workspaces/current, /workspaces/wallet
workspace switch POST /workspaces/switch

If the browser calls an app-local route for generation, that app route must be a mutation route such as POST /api/generate; it must not be GET. The route then checks auth server-side and calls jobs.submit(...). Do not forward browser requests directly to fnf.internal, /jobs, /jobs/v2/*, or /api/user.

For TanStack server functions, use createServerFn({ method: "POST" }) for submit, cost, media upload, workspace switch, and other write-like SDK operations. Use GET only for pure read functions. A 405 / "Method Not Allowed" on generation almost always means the generated website used a GET route/function for a submit/cost/media operation, or tried to call a fnf.internal write operation through a query string instead of letting the SDK use the static POST route.

Strict Media Upload Contract

Binary files must never cross a JSON server-function boundary. Do not pass File, Blob, ArrayBuffer, Uint8Array, base64 strings, or arrays of bytes inside createServerFn input or JSON request bodies. This causes stack overflows, huge payloads, broken serialization, or unusable object-shaped bytes.

Use one of these two patterns:

  1. Browser uploads to an app-local POST /api/media/upload route with FormData.
  2. Browser uses useAttachments only when the provided media client has a safe browser-capable upload path. For server-only fnf.internal/auth flows, prefer the FormData route.

Then generation submits only the returned MediaRef:

File in browser
  -> POST multipart FormData /api/media/upload
  -> server auth guard
  -> media.upload({ source: bytes, ... })
  -> returns MediaRef
  -> POST JSON /api/generate with prompt/settings/MediaRef only
  -> server auth guard
  -> jobs.submit(...)

Client upload example:

const form = new FormData()
form.append('file', file)

const response = await fetch('/api/media/upload', {
  method: 'POST',
  body: form,
})

const upload = await response.json()
if (!upload.ok)
  throw new Error(upload.error?.code ?? 'upload_failed')

Do not set the content-type header manually for FormData; the browser must add the multipart boundary.

Server upload route example:

import { createFileRoute } from '@tanstack/react-router'
import { createMediaClient } from '@higgsfield/fnf/media'

export const Route = createFileRoute('/api/media/upload')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const auth = await requireCurrentUser()
        if (!auth.ok) {
          return Response.json(
            { ok: false, error: { code: 'unauthorized', status: auth.status } },
            { status: auth.status },
          )
        }

        const form = await request.formData()
        const file = form.get('file')
        if (!(file instanceof File)) {
          return Response.json(
            { ok: false, error: { code: 'missing_file' } },
            { status: 400 },
          )
        }

        console.info('[api/media/upload] file', {
          contentType: file.type,
          size: file.size,
        })

        const bytes = new Uint8Array(await file.arrayBuffer())
        const media = createMediaClient({ mediaAdapter: adapter })
        const result = await media.upload({
          source: bytes,
          filename: 'upload',
          contentType: file.type,
          type: 'image',
          forceIpCheck: true,
        })

        return Response.json({ ok: true, ref: result.ref })
      },
    },
  },
})

Generation route input must look like this:

{
  prompt: string
  settings: { aspectRatio: '3:4', resolution: '1k', batchSize: 1 }
  media?: { image?: MediaRef }
}

Do not include file, blob, bytes, arrayBuffer, base64, or raw data:image/... values in generation input. If upload fails with Maximum call stack size exceeded, the website almost certainly tried to serialize binary through JSON or used String.fromCharCode(...bytes) on a large file.

Job Client Pattern

Only call the job client after the server auth guard succeeds.

Register exactly the models the website exposes:

const jobs = createJobClient({
  adapter,
  jobs: [seedance2_0, nanoBanana2],
})

Use public camelCase settings:

await jobs.submit({
  model: 'seedance_2_0',
  prompt: { instruction: prompt },
  settings: {
    mode: 'std',
    duration: 5,
    aspectRatio: '16:9',
    resolution: '720p',
    batchSize: 1,
  },
})

Never send snake_case settings from UI code. The SDK maps aspectRatio to aspect_ratio, batchSize to batch_size, and similar wire fields through job schemas.

Server-function submit example:

import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { createJobClient } from '@higgsfield/fnf/client'
import { createWorkflowPlatformAdapter } from '@higgsfield/fnf/workflow-platform'
import { nanoBanana2 } from '@higgsfield/fnf/jobs'

export const generate = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    prompt: z.string().min(1),
    // set by the browser confirmation modal — see "Submission Confirmation Gate"
    confirmed: z.literal(true),
  }))
  .handler(async ({ data }) => {
    const auth = await requireCurrentUser()
    if (!auth.ok) {
      return {
        ok: false as const,
        code: 'unauthorized',
        status: auth.status,
      }
    }

    const adapter = createWorkflowPlatformAdapter({
      baseUrl: 'https://fnf.internal',
      confirm: async () => {
        if (!data.confirmed)
          throw new Error('user did not confirm the submission')
      },
    })

    const jobs = createJobClient({ adapter, jobs: [nanoBanana2] })
    const result = await jobs.submit({
      model: 'nano_banana_2',
      prompt: { instruction: data.prompt },
      settings: { aspectRatio: '3:4', resolution: '1k', batchSize: 1 },
    })

    return { ok: true as const, result }
  })

Do not use createServerFn({ method: "GET" }) or a GET server route for generation. Generation is a mutation even when the form has no uploaded file.

LLM chat / text — createLlmClient (NOT a job)

Chat/text generation is separate from the media jobs registry. Worker-side ONLY, zero-token (no getToken/userId — the platform attaches the visitor's identity and bills their credits):

import { createLlmClient } from '@higgsfield/fnf'
const llm = createLlmClient({ baseUrl: 'https://fnf.internal/llm' })

const [model] = await llm.listModels()
if (!model) throw new Error('No LLM models are currently available')

const res = await llm.complete({
  model,
  messages: [{ role: 'user', content: '…' }],
})
// llm.stream(...) yields OpenAI-shape SSE deltas; tool-calling supported
// (LlmToolDef / LlmToolCall). In React: FnfProvider's `llm` prop + useFnfLlmClient().

Model availability is runtime gateway configuration. Never hardcode a catalog or claim that one model is always available. Call listModels() server-side, use the exact returned id, and handle an empty list. Model pickers may receive those ids through an authenticated app-local server function or route; if a previously selected id disappears, refresh the list and show that it is no longer available instead of silently substituting another model.

App-only. Text generation is generation → type: "app" (Sign in with Higgsfield, visitor credits). NEVER on a type: "website" build, and never a "bring your own LLM key" path.

Submission Confirmation Gate

The SDK requires hosts that submit generations on behalf of a user to implement a confirmation gate. Every generation adapter factory accepts a confirm option (ConfirmSubmit from @higgsfield/fnf); jobs.submit calls it once per submission — after validation, before any network call. Resolve to proceed (a resolved string rides the create request as confirmation_token); reject/throw to abort with the typed confirmation_rejected error.

In a generated website the submit runs server-side, so split the gate across the boundary:

  1. Browser: before calling the generate server function, show a Quanta confirmation modal with the model, a settings summary, and the cost preview. Only call the generate function after the user confirms; send confirmed: true (or an app-minted confirmation token) in the JSON input.
  2. Server: wire the adapter's confirm to that input — resolve when the browser confirmed, throw when it did not:
const adapter = createWorkflowPlatformAdapter({
  baseUrl: 'https://fnf.internal',
  // Runs during jobs.submit, after validation, before any network call.
  confirm: async () => {
    if (!data.confirmed)
      throw new Error('user did not confirm the submission')
    return data.confirmationToken // optional; sent as confirmation_token
  },
})

Rules:

Generation Result Rendering Contract

Every real generation website must show submitted and historical generations with real media previews. Use the template helpers by default:

import { HiggsfieldGenerationCard } from "@/components/higgsfield-generation-card"
import { selectGenerationMedia } from "@/lib/higgsfield-generation-results"

The helper uses the SDK read model:

When writing custom cards, keep the same selector contract:

import {
  getJobPhase,
  getMediaType,
  getPreviewUrl,
  getRawUrl,
  hasResult,
  isTerminalJobStatus,
} from "@higgsfield/fnf/client"

Never inspect raw backend results.raw.url objects in website UI. The SDK adapter normalizes product/WFP responses into Generation.results; render that model. Never log prompts or raw media URLs. Displaying the signed-in user's prompt in their own UI is allowed.

Model Catalog Snapshot

Use the package guide as source of truth, but common generated-website choices are:

Import only the entries the screen needs. The jobs: [...] array is the source of model autocomplete and media-role narrowing.

Media Pattern

Use createMediaClient({ mediaAdapter: adapter }) server-side after auth, or behind a safe client boundary that still reaches a server auth guard. Upload first, then submit the returned MediaRef:

const upload = await media.upload({
  source: fileBytes,
  filename: 'input.png',
  type: 'image',
  forceIpCheck: true,
})

await jobs.submit({
  model: 'nano_banana_2',
  media: { image: upload.ref },
  prompt: { instruction: prompt },
})

Attach/resolve media metadata before submit when local validation depends on dimensions or duration. Unknown metadata is allowed locally; backend remains authoritative.

Allowed media.upload({ source }) values are only Blob, ArrayBuffer, Uint8Array, or { read: async () => Blob | ArrayBuffer | Uint8Array }. The SDK throws invalid_media_source for JSON-shaped objects so broken upload plumbing fails clearly before presign/transfer.

Profile And Workspace

Use the profile domain for account/workspace panels:

const snapshot = await profile.getSnapshot()
const credits = await profile.getCredits({ includeOnDemand: true })
await profile.switchWorkspace({ workspaceId })

Workspace switching updates backend context only. The website still owns routing, session metadata, adapter header state, and cache invalidation.

Do not use profile.getUser() directly in browser components for auth gating. For browser UI, use /api/user from references/auth.md. Use the SDK profile client server-side when the website is already performing SDK-backed account, workspace, wallet, or credit operations.

Credit display rule:

Marketplace / Service Adapters Are Not For Generated Websites

Some fnf snapshots include createAppsMarketplaceAdapter. It is server-side only, defaults to the dev apps-marketplace backend for now, and is for SDK smoke tests or trusted service experiments only. Generated websites must not use it. Generated websites use createWorkflowPlatformAdapter({ baseUrl: 'https://fnf.internal' }) exclusively.

Job UI Pattern

For a generated website UI:

  1. Build the form with Quanta components.
  2. Load /api/user before enabling SDK-backed controls.
  3. If signed out, show sign-in UI and disable/hide generation, upload, cost, feed/history, profile, workspace, and credit actions.
  4. Confirm before submit: show the confirmation modal (model + cost) and call the generate function only after the user confirms; wire the adapter confirm option server-side (see "Submission Confirmation Gate").
  5. Keep submit/cost/profile/media calls in server functions or safe app-local server-only modules.
  6. Re-check auth inside each server function before calling SDK clients.
  7. Return only safe data to the browser: generation ids, statuses, display credits, and sanitized errors.
  8. Use safeSubmit/typed error codes when crossing worker/iframe boundaries.
  9. Show validation, cost, upload state, running state, terminal state, cancelled-confirmation state (confirmation_rejected), and typed errors as real UI states.

Do not build anonymous real generation. The only allowed anonymous SDK-looking flow is a mock/offline demo the user EXPLICITLY asked for (memory adapters, no network requests) — never the default.

Troubleshooting Generation 405

If sign-in works but generation fails with Method Not Allowed:

  1. Confirm the app-local generation function/route is POST, not GET.
  2. Confirm the code calls jobs.submit(...) or jobs.cost(...) through the SDK, not hand-written fetch('/jobs?...').
  3. Confirm createWorkflowPlatformAdapter({ baseUrl }) uses exactly https://fnf.internal, not the deployed website URL, /api/user, a public fnf URL, a dev fnf URL, or a model-specific fnf route.
  4. Confirm the server handler re-checks https://fnf.internal/user before SDK calls, then creates the adapter/client inside the server handler.
  5. Confirm reads use SDK-backed GET routes such as /jobs, /jobs/{id}, or /jobs/sets/{id}, while submit/cost/cancel/media writes use SDK-backed POST routes such as /jobs/submit, /jobs/cost, and /jobs/media/presign. Do not send submit/cost/media operations through GET query parameters.

If upload fails with Maximum call stack size exceeded:

  1. Search for String.fromCharCode(..., base64 conversion, JSON.stringify(file), Array.from(bytes), or passing File/Blob/ArrayBuffer to createServerFn input.
  2. Replace that flow with FormData POST to /api/media/upload.
  3. Return only { ref: MediaRef } from upload.
  4. Send only that MediaRef in generation JSON.
  5. Add logs for file size and contentType, but never file bytes, upload URL, token, raw prompt, or raw backend body.

Observability

SDK observability is a safe metadata callback. It is not product analytics by default and it must not leak prompts, params, headers, tokens, URLs, filenames, emails, workspace names, or raw bodies.

Allowed metadata examples: model id, operation, status, duration, safe job id, safe media id, media type, credit estimate, and error code.

Pass observability to the adapter as well as clients when debugging transport problems:

const adapter = createWorkflowPlatformAdapter({
  baseUrl: 'https://fnf.internal',
  observability,
})

const jobs = createJobClient({ adapter, jobs: [nanoBanana2], observability })
const media = createMediaClient({ mediaAdapter: adapter, observability })

Without adapter observability you may see fnf.job.submit fail but not the underlying fnf.transport.request method/path/status.