DEV Community

SpicyAPI
SpicyAPI

Posted on AI-assisted

Keep an AI Character Consistent Through an API: One Saved Reference, Many Shots

You can get a great-looking character out of an image model in one prompt. Getting the same character in the next ten images is the hard part. Describe her again in words and the glasses change shape, the hair drifts a shade, and by the tenth post nobody can say what she looks like.

The fix is procedural rather than clever prompting: pick one image, store it, and make every later generation start from that stored image instead of from text. This post builds that pipeline against an HTTP API in about 100 lines of plain Node.js: candidates, a saved reference, a photo set, and a short clip.

The pipeline

  1. Candidates. Generate a few head-and-shoulders portraits on a plain background with a text-to-image model.
  2. Reference. Save the one you like to a persistent asset library. You get back a stable URI.
  3. Photo set. Call an image edit model with that URI as the reference image and a new scene in the prompt. Repeat per scene.
  4. Clip. Save one of those photos too and pass it as the opening frame of an image-to-video model.

The important rule is in step 3: every new picture starts from the saved reference, never from your latest result. Chaining result-to-result is how small changes pile up.

Models used below (prices at the time of writing, October 2026):

Step Model Price
Candidates bytedance/seedream-5.0-pro/text-to-image $0.036 per image at 1k
Photo set bytedance/seedream-5.0-pro/edit $0.036 per image at 1k
Clip alibaba/wan-3.0/image-to-video $0.09 per second at 720p

Four candidates, three photos and one five-second clip come to about $0.70.

A tiny client

No SDK needed. The API wraps every response in { code, msg, data, request_id }, tasks are asynchronous, and writes accept an Idempotency-Key so a retried request does not create (or bill) a second task.

// spicy.mjs: a tiny helper around the SpicyAPI task API (Node 18+, no dependencies)
import { randomUUID } from 'node:crypto';

const BASE = 'https://api.spicyapi.ai';
const KEY = process.env.SPICY_API_KEY;

async function call(method, path, body, idempotencyKey) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) {
    const reason = json.data?.reason ? ` [${json.data.reason}]` : '';
    throw new Error(`${res.status} ${json.msg}${reason} (request_id ${json.request_id})`);
  }
  return json.data;
}

// What a request would cost, without running it
export const quote = (model, input) =>
  call('POST', '/api/v1/jobs/quote', { model, input });

// Create a task, wait up to 60 s on the first call, then poll until it finishes
export async function run(model, input, idempotencyKey = randomUUID()) {
  let task = await call('POST', '/api/v1/jobs/createTask?wait=60', { model, input }, idempotencyKey);
  while (!['succeeded', 'failed', 'canceled', 'expired'].includes(task.state)) {
    await new Promise((r) => setTimeout(r, 5000));
    task = await call('GET', `/api/v1/jobs/recordInfo?taskId=${task.taskId}`);
  }
  if (task.state !== 'succeeded') {
    throw new Error(`${task.taskId} ${task.state}: ${task.errorCode} ${task.errorMessage ?? ''}`);
  }
  return task;
}

// Copy one output of a finished task into the asset library and get a stable spicy://a/ URI
export const saveAsset = (taskId, index, name) =>
  call('POST', '/api/v1/assets', { source: 'task_output', taskId, index, name }, `save-${taskId}-${index}`);
Enter fullscreen mode Exit fullscreen mode

Two details worth knowing:

  • ?wait=60 holds the first request open for up to 60 seconds. Image jobs often come back finished in that one call, and you only poll when they don't. If you disconnect, the task keeps running.
  • The save call uses a deterministic idempotency key (save-<task>-<index>), so running the script twice does not store the same image twice.

The script

// character.mjs: candidates -> saved reference -> photo set -> one short clip
import { quote, run, saveAsset } from './spicy.mjs';

const T2I = 'bytedance/seedream-5.0-pro/text-to-image';
const EDIT = 'bytedance/seedream-5.0-pro/edit';
const I2V = 'alibaba/wan-3.0/image-to-video';

const mara =
  'Mara, a fictional plant-care host in her early thirties: copper hair in a low bun, ' +
  'round tortoiseshell glasses, mustard-yellow linen apron over a white T-shirt';

// 1. Four candidates for the face, on a plain background
const pickTask = process.env.PICK_TASK;
if (!pickTask) {
  for (let i = 0; i < 4; i++) {
    const task = await run(T2I, {
      prompt: `Head-and-shoulders portrait of ${mara}. Plain white studio background, soft even light, natural skin texture.`,
      resolution: '1k',
      aspect_ratio: '4:5',
    });
    console.log(`${task.taskId}: ${task.output.assets[0].url}`);
  }
  console.log('Rerun with PICK_TASK=<taskId> of the face you like.');
  process.exit(0);
}

// 2. Keep the one you like as the permanent reference
const reference = await saveAsset(pickTask, 0, 'Mara reference');
console.log('reference:', reference.uri); // spicy://a/ast_...

// 3. A photo set: every shot starts from the saved reference, not from text
const scenes = [
  'repotting a monstera at a sunny kitchen table',
  'misting a row of ferns on a rainy windowsill',
  'reading a seed catalogue on a small balcony at dusk',
];
const photos = [];
for (const scene of scenes) {
  const input = {
    prompt: `The woman from the reference image, ${scene}. Keep her face, hair, glasses and apron exactly the same. Natural light, 35mm photo.`,
    image_urls: [reference.uri],
    resolution: '1k',
    aspect_ratio: '4:5',
  };
  const { estimatedCost } = await quote(EDIT, input);
  const task = await run(EDIT, input);
  photos.push(task);
  console.log(`$${estimatedCost} ${scene}: ${task.output.assets[0].url}`);
}

// 4. Animate the first photo for five seconds
const still = await saveAsset(photos[0].taskId, 0, 'Mara repotting');
const clip = await run(I2V, {
  image_url: still.uri,
  prompt: 'She presses the soil down around the plant, then looks up with a small smile. Camera holds steady at eye level. Warm morning light.',
  resolution: '720p',
  duration_seconds: 5,
});
console.log('clip:', clip.output.assets[0].url);
Enter fullscreen mode Exit fullscreen mode

Run it twice. SPICY_API_KEY=sk-spicy-... node character.mjs generates the four candidates and prints each task ID with its image URL, then stops. Open the URLs, choose a face, and run it again with PICK_TASK=<that task ID> to save the reference and make the photo set and the clip. Picking by task ID rather than by position matters: a second run would otherwise generate four new faces.

Why the saved URI matters

Output URLs in output.assets[].url are signed links that expire after about 20 minutes, and results are kept for 14 days. That is fine for downloading, not for a character you will use for months.

POST /api/v1/assets copies the file into a persistent library and returns an asset with a uri like spicy://a/ast_.... That URI:

  • works in any media field of a model input (image_urls, image_url, and so on), so you never re-upload the reference;
  • only resolves for API keys of the same workspace, so it is not a public link and the library cannot be used as file hosting;
  • stays valid until you delete the asset.

Store the uri in your own database next to the character, not the preview URL.

The library unlocks with your account's lifetime top-ups (at the time of writing, $10 in total gives 500 MB and up to 200 saved assets). Without that, the save call returns 400 with data.reason set to asset_library_locked, which the helper above prints. GET /api/v1/assets/usage shows the current plan and limits.

Prompting the edit model

The reference image carries the face; the prompt should carry the change. A few things that help:

  • Name what must stay the same. "Keep her face, hair, glasses and apron exactly the same" does more than repeating the full description.
  • Change one thing per shot: the place, the activity or the outfit, not all three.
  • Keep the signature details visible. Pick two or three (the glasses, the copper hair, the yellow apron) and frame shots so they show. They are what makes a thumbnail recognisable.
  • Compare side by side. Every few weeks put six recent images next to the reference. If a stranger would not call them the same person, go back to the reference instead of the latest image.

Quote before you spend

POST /api/v1/jobs/quote takes the same body as createTask and returns estimatedCost without running anything. The script prints it per photo. In a real tool you would show it to the user before the button does anything, or refuse requests above a budget. When a task is created, estimatedCost is held and the final charge never goes above it.

Doing it without code

If you just want to try the idea before wiring it into anything, the same flow exists as a page: an AI influencer builder where you pick traits from picture cards, keep a close-up and a full-length photo, save the character, and then make photo sets of 4, 8 or 12 pictures, short videos or lip-synced talking clips from it. Under the hood it is the reference-first approach described above.

Running a persona responsibly

A consistent fictional character is a reasonable thing to run as a brand mascot or a channel host, with a few rules:

  • Label it. Many social platforms have AI-content labels and some require them. Say in the profile that the character is AI-generated.
  • Do not imitate a real person, and only start from a photo of someone if you have the right to use that face.
  • Keep the character a fictional adult, and write that into your persona notes.

Where to go next

  • Swap the models: any image edit model that accepts reference images works for step 3, and the API docs list what each one accepts.
  • Add a talking clip: generate speech for a line of dialogue, then run a talking-avatar model on one of the saved photos.
  • Read the model page for Seedream 5.0 Pro for its resolution tiers and reference-image limits (up to 10 per request).

The whole trick is one habit: store the reference once, and point every later request at it.

Top comments (0)