Skill: newt0n/creatok-generate-image (version 0.1.3)

--- name: creatok-generate-image version: "1.0.0" description: "This skill should be used when the user asks to generate an image, create an AI image, produce a product image, generate a visual from a prompt, or check and continue an existing image generation task. Generates images through CreatOK's image generation API and can also recover interrupted generation flows from an existing task id." license: Open Source compatibility: "Claude Code ≥1.0, OpenClaw skills, ClawHub-compatible installers. Requires network access to CreatOK Open Skills API. No local image rendering packages required." metadata: openclaw: requires: env: [] bins: - node primaryEnv: CREATOK_API_KEY author: creatok version: "1.0.0" geo-relevance: "low" tags: - image-generation - ai-image - text-to-image - product-image - gpt-image - seedream - nano-banana - prompt-to-image triggers: - "generate an image" - "create an image" - "generate a product image" - "make an image from this prompt" - "create an AI image" - "generate a visual" - "text to image" - "produce an image" - "start image generation" - "generate it as an image" - "check this image generation task" - "did my image finish" - "check this image task id" - "画像を生成して" - "画像を作って" - "商品画像を作って" - "このプロンプトで画像を作って" - "이미지 생성해줘" - "이미지 만들어줘" - "상품 이미지 만들어줘" - "이 프롬프트로 이미지를 만들어줘" - "genera una imagen" - "crea una imagen" - "genera una imagen de producto" - "convierte este prompt en una imagen" - "gere uma imagem" - "crie uma imagem" - "gere uma imagem de produto" - "transforme este prompt em uma imagem" --- # generate-image ## Constraints - The model's final user-facing response should match the user's input language, default **English**. - Must request **user confirmation** before triggering any paid/high-cost image generation call. - After confirmed, must call **CreatOK Open Skills proxy** and wait until completion. - Avoid technical wording in the user-facing reply unless the user explicitly needs details for debugging. ## Model Selection Rules The skill must read model list, defaults, and hard limits from the CreatOK capabilities endpoint at runtime instead of hardcoding them locally. Keep the local client thin: - use the returned default model unless the user explicitly chooses another supported model - validate only hard execution constraints such as resolution, aspect ratio, count, and reference-image cap - keep user-facing model explanation minimal and avoid depending on provider, family, or hint-style metadata ## Inputs to clarify (ask if missing) - ask only for what is necessary to generate a good image - if resolution or aspect ratio is not specified, use the model defaults returned by the capabilities endpoint - if the prompt is vague, offer to refine it before confirming generation - reference images are optional — ask only if the user implies style transfer or subject reference - when reference images are used, upload the local image file first and submit the returned uploaded reference with the generation task ## Workflow 1. **Confirmation gate** (mandatory) - Summarize: - model - resolution - number of images (`n`) - aspect ratio if specified - estimated cost/credits if available - Ask for a simple confirmation before submitting. - Do **not** submit the generation task until user says yes. 2. Submit image generation - Call CreatOK: `POST /api/open/skills/image-generation` 3. Poll status until completion - Call CreatOK: `GET /api/open/skills/tasks/status?task_id=...&task_type=image_generation` 4. Persist artifacts + respond - Write: - `outputs/result.json` with `task_id/status/images/raw` - `outputs/result.md` - Persist the `task_id` immediately after submission so the user can recover later. - Return the final image URLs verbatim. ## Existing Task Recovery - If the user already has a `task_id`, continue from that task instead of starting a new one. - In recovery mode, do not ask the user to restate the prompt if the task id is already available. - The model can either check status once or keep polling if the user wants to wait. - If the task succeeded, return the final image URLs verbatim. - If the task is still queued or running, explain clearly and offer to keep checking. - If the task failed, explain the failure and suggest next steps. ## Artifacts All artifacts under `generate-image/.artifacts/<run_id>/...`. ## Thin Client Boundary - This skill submits generation jobs, polls status, and persists fixed-format outputs. - The model should not make the user restate their idea if the direction is already clear from the conversation.

path: lib/artifacts.js
content: const fs = require('node:fs');
const path = require('node:path');

class Artifacts {
  constructor(root) {
    this.root = root;
  }

  ensure() {
    for (const name of ['input', 'transcript', 'vision', 'outputs', 'logs']) {
      fs.mkdirSync(path.join(this.root, name), { recursive: true });
    }
  }

  writeJson(relPath, obj) {
    const filePath = path.join(this.root, relPath);
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
    return filePath;
  }

  writeText(relPath, text) {
    const filePath = path.join(this.root, relPath);
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, text, 'utf8');
    return filePath;
  }
}

function artifactsRootForSkill(skillDir) {
  return path.join(skillDir, '.artifacts');
}

function artifactsForRun(skillDir, runId) {
  return new Artifacts(path.join(artifactsRootForSkill(skillDir), runId));
}

module.exports = {
  Artifacts,
  artifactsRootForSkill,
  artifactsForRun,
};


path: lib/capabilities.js
content: function invalidContract(message) {
  return new Error(`Invalid capabilities contract: ${message}`);
}

function isPlainObject(value) {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function assertStringArray(value, field) {
  if (!Array.isArray(value) || value.length === 0 || value.some((item) => !isNonEmptyString(item))) {
    throw invalidContract(`${field} must be a non-empty array of strings`);
  }
}

function normalizePricing(pricing, field) {
  if (!isPlainObject(pricing)) {
    return null;
  }
  if (pricing.currency !== 'credits') {
    throw invalidContract(`${field}.currency must be 'credits'`);
  }
  if (pricing.unit !== 'image') {
    throw invalidContract(`${field}.unit must be 'image'`);
  }
  if (!isPlainObject(pricing.by_resolution)) {
    throw invalidContract(`${field}.by_resolution must be an object`);
  }
  const byResolution = {};
  for (const [resolution, credits] of Object.entries(pricing.by_resolution)) {
    if (!isNonEmptyString(resolution)) {
      throw invalidContract(`${field}.by_resolution keys must be non-empty strings`);
    }
    if (typeof credits !== 'number' || Number.isNaN(credits)) {
      throw invalidContract(`${field}.by_resolution.${resolution} must be a number`);
    }
    byResolution[resolution] = Number(credits);
  }
  return {
    currency: 'credits',
    unit: 'image',
    byResolution,
  };
}

function assertNRange(range, field) {
  if (!isPlainObject(range)) {
    throw invalidContract(`${field} must be an object`);
  }
  if (typeof range.min !== 'number' || typeof range.max !== 'number') {
    throw invalidContract(`${field}.min and ${field}.max must be numbers`);
  }
}

function normalizeImageModel(model, field) {
  if (!isPlainObject(model)) {
    throw invalidContract(`${field} must be an object`);
  }
  if (!isNonEmptyString(model.id)) {
    throw invalidContract(`${field}.id must be a non-empty string`);
  }
  if (!isNonEmptyString(model.label)) {
    throw invalidContract(`${field}.label must be a non-empty string`);
  }
  if (!isPlainObject(model.defaults)) {
    throw invalidContract(`${field}.defaults must be an object`);
  }
  if (!isPlainObject(model.limits)) {
    throw invalidContract(`${field}.limits must be an object`);
  }
  if (!isNonEmptyString(model.defaults.resolution)) {
    throw invalidContract(`${field}.defaults.resolution must be a non-empty string`);
  }
  if (typeof model.defaults.n !== 'number' || Number.isNaN(model.defaults.n)) {
    throw invalidContract(`${field}.defaults.n must be a number`);
  }
  assertStringArray(model.limits.resolutions, `${field}.limits.resolutions`);
  assertNRange(model.limits.n, `${field}.limits.n`);
  if (model.limits.aspect_ratios != null) {
    assertStringArray(model.limits.aspect_ratios, `${field}.limits.aspect_ratios`);
  }
  if (typeof model.limits.max_reference_images !== 'number' || Number.isNaN(model.limits.max_reference_images)) {
    throw invalidContract(`${field}.limits.max_reference_images must be a number`);
  }

  return {
    id: model.id,
    label: model.label,
    defaults: {
      resolution: model.defaults.resolution,
      n: Number(model.defaults.n),
    },
    limits: {
      resolutions: [...model.limits.resolutions],
      n: {
        min: Number(model.limits.n.min),
        max: Number(model.limits.n.max),
      },
      aspectRatios: Array.isArray(model.limits.aspect_ratios) ? [...model.limits.aspect_ratios] : null,
      maxReferenceImages: Number(model.limits.max_reference_images),
    },
    pricing: normalizePricing(model.pricing, `${field}.pricing`),
  };
}

function normalizeImageCapabilities(payload) {
  const field = 'capabilities.image';
  if (!isPlainObject(payload)) {
    throw invalidContract(`${field} must be an object`);
  }
  if (!isNonEmptyString(payload.default_model)) {
    throw invalidContract(`${field}.default_model must be a non-empty string`);
  }
  if (!Array.isArray(payload.models)) {
    throw invalidContract(`${field}.models must be an array`);
  }

  const models = payload.models.map((model, index) => normalizeImageModel(model, `${field}.models[${index}]`));
  if (models.length === 0) {
    throw invalidContract(`${field}.models must include at least one model`);
  }

  if (!models.some((model) => model.id === payload.default_model)) {
    throw invalidContract(`${field}.default_model must reference a model`);
  }

  return {
    defaultModel: payload.default_model,
    models,
  };
}

function buildUnsupportedImageModelError(imageCapabilities, modelId) {
  return new Error(
    `Unsupported model: ${modelId}. Supported: ${imageCapabilities.models.map((model) => model.id).join(', ')}`,
  );
}

function summarizeModelSelection(model, { estimatedCredits = null } = {}) {
  const pricing = estimatedCredits == null ? '' : ` Estimated cost: ${estimatedCredits} credits.`;
  return `${model.label}.${pricing}`;
}

async function fetchImageCapabilities({ client = null, timeoutSec = 60, defaultClient = null } = {}) {
  const effectiveClient = client || (typeof defaultClient === 'function' ? defaultClient() : null);
  if (!effectiveClient || typeof effectiveClient.getCapabilities !== 'function') {
    throw new Error('Missing capabilities client. Expected getCapabilities().');
  }

  const payload = await effectiveClient.getCapabilities(timeoutSec);
  if (!isPlainObject(payload)) {
    throw invalidContract('payload must be an object');
  }
  if (!isPlainObject(payload.capabilities)) {
    throw invalidContract('capabilities must be an object');
  }

  return normalizeImageCapabilities(payload.capabilities.image);
}

module.exports = {
  buildUnsupportedImageModelError,
  fetchImageCapabilities,
  summarizeModelSelection,
};


path: lib/config.js
content: function getCreatokConfig() {
  const [REDACTED_SECRET];

  if (!apiKey) {
    throw new Error('Missing CREATOK_API_KEY. Set env CREATOK_API_KEY before running this skill.');
  }

  return {
    baseUrl: 'https://www.creatok.ai',
    openSkillsKey: String(apiKey),
  };
}

module.exports = {
  getCreatokConfig,
};


path: lib/creatok-capabilities-client.js
content: const { getCreatokConfig } = require('./config');

function summarizeHttpError(status, url) {
  const pathname = (() => {
    try {
      return new URL(url).pathname;
    } catch {
      return url;
    }
  })();
  if (status === 401) {
    return `Unauthorized calling ${pathname}. Check CREATOK_API_KEY.`;
  }
  if (status === 404) {
    return `Endpoint not found at ${pathname}. Check the CreatOK base URL and deployment.`;
  }
  if (status >= 500) {
    return `CreatOK server error calling ${pathname}. Try again later.`;
  }
  return `HTTP ${status} calling ${pathname}.`;
}

class CreatokCapabilitiesClient {
  constructor(cfg) {
    this.cfg = cfg;
  }

  async requestJson(method, url, { timeoutSec = 60 } = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSec) * 1000);

    try {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${this.cfg.openSkillsKey}`,
          Accept: 'application/json',
        },
        signal: controller.signal,
      });

      const text = await response.text();
      let payload;
      try {
        payload = JSON.parse(text);
      } catch {
        throw new Error(`Invalid response from CreatOK endpoint ${url}.`);
      }

      if (!response.ok) {
        throw new Error(summarizeHttpError(response.status, url));
      }

      return payload;
    } finally {
      clearTimeout(timeout);
    }
  }

  async getCapabilities(timeoutSec = 60) {
    const payload = await this.requestJson('GET', `${this.cfg.baseUrl}/api/open/skills/capabilities`, {
      timeoutSec,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK capabilities failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }
}

function defaultCapabilitiesClient() {
  return new CreatokCapabilitiesClient(getCreatokConfig());
}

module.exports = {
  CreatokCapabilitiesClient,
  defaultCapabilitiesClient,
};


path: lib/creatok-client.js
content: const fs = require('node:fs/promises');
const path = require('node:path');
const { getCreatokConfig } = require('./config');

function summarizeHttpError(status, url) {
  const pathname = (() => {
    try {
      return new URL(url).pathname;
    } catch {
      return url;
    }
  })();
  if (status === 401) {
    return `Unauthorized calling ${pathname}. Check CREATOK_API_KEY.`;
  }
  if (status === 404) {
    return `Endpoint not found at ${pathname}. Check the CreatOK base URL and deployment.`;
  }
  if (status >= 500) {
    return `CreatOK server error calling ${pathname}. Try again later.`;
  }
  return `HTTP ${status} calling ${pathname}.`;
}

function contentTypeForFile(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  const map = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.webp': 'image/webp',
    '.gif': 'image/gif',
  };
  const type = map[ext];
  if (!type) {
    throw new Error(`Unsupported image file type for upload: ${filePath}`);
  }
  return type;
}

class CreatokOpenSkillsClient {
  constructor(cfg) {
    this.cfg = cfg;
  }

  async requestJson(method, url, { body, timeoutSec = 60 } = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSec) * 1000);

    try {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${this.cfg.openSkillsKey}`,
          Accept: 'application/json',
          ...(body ? { 'Content-Type': 'application/json' } : {}),
        },
        body: body ? JSON.stringify(body) : undefined,
        signal: controller.signal,
      });

      const text = await response.text();
      let payload;
      try {
        payload = JSON.parse(text);
      } catch (error) {
        throw new Error(`Invalid response from CreatOK endpoint ${url}.`);
      }

      if (!response.ok) {
        throw new Error(summarizeHttpError(response.status, url));
      }

      return payload;
    } finally {
      clearTimeout(timeout);
    }
  }

  async submitImageTask({ prompt, model, resolution, n, aspectRatio, referenceImages }) {
    const body = { prompt, model, resolution, n };
    if (aspectRatio) body.aspect_ratio = aspectRatio;
    if (referenceImages && referenceImages.length > 0) body.imageObjectKeys = referenceImages;

    const payload = await this.requestJson('POST', `${this.cfg.baseUrl}/api/open/skills/image-generation`, {
      body,
      timeoutSec: 60,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK image task submission failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }

  async createImageUpload({ fileName, fileType, fileSize, prefix, timeoutSec = 60 }) {
    const payload = await this.requestJson('POST', `${this.cfg.baseUrl}/api/open/skills/upload/image/presigned`, {
      body: { fileName, fileType, fileSize, prefix },
      timeoutSec,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK image upload init failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }

  async uploadImageFile(filePath, { prefix = 'open-skills/reference-images', timeoutSec = 60 } = {}) {
    const fileName = path.basename(filePath);
    const fileType = contentTypeForFile(filePath);
    const file = await fs.readFile(filePath);
    const upload = await this.createImageUpload({
      fileName,
      fileType,
      fileSize: file.byteLength,
      prefix,
      timeoutSec,
    });
    if (!upload.presignedUploadUrl || !upload.objectKey) {
      throw new Error(`Invalid upload init response: ${JSON.stringify(upload)}`);
    }

    const response = await fetch(upload.presignedUploadUrl, {
      method: 'PUT',
      headers: { 'Content-Type': fileType },
      body: file,
    });
    if (!response.ok) {
      throw new Error(`Image upload failed with HTTP ${response.status}.`);
    }
    return upload.objectKey;
  }

  async getTaskStatus(taskId) {
    const payload = await this.requestJson(
      'GET',
      `${this.cfg.baseUrl}/api/open/skills/tasks/status?task_id=${encodeURIComponent(taskId)}&task_type=image_generation`,
      { timeoutSec: 60 },
    );
    if (payload.code !== 0) {
      throw new Error(`CreatOK task status failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }
}

function defaultClient() {
  return new CreatokOpenSkillsClient(getCreatokConfig());
}

module.exports = {
  CreatokOpenSkillsClient,
  defaultClient,
};


path: lib/generate-image.js
content: const { artifactsForRun } = require('./artifacts');
const { defaultClient } = require('./creatok-client');
const { buildUnsupportedImageModelError, fetchImageCapabilities, summarizeModelSelection } = require('./capabilities');
const { defaultCapabilitiesClient } = require('./creatok-capabilities-client');

function capabilitiesOptions(client, timeoutSec) {
  return {
    client: typeof client.getCapabilities === 'function' ? client : null,
    defaultClient: defaultCapabilitiesClient,
    timeoutSec,
  };
}

function parseImageCount(n) {
  const parsed = Number(n);
  if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
    throw new Error('n must be a finite integer');
  }
  return parsed;
}

async function resolveGenerateImageRequest({
  model = null,
  resolution = null,
  n = null,
  referenceImages = [],
  timeoutSec = 300,
  client = defaultClient(),
}) {
  const imageCapabilities = await fetchImageCapabilities(capabilitiesOptions(client, timeoutSec));
  const finalModel = model || imageCapabilities.defaultModel;
  const modelCapability = imageCapabilities.models.find((item) => item.id === finalModel);

  if (!modelCapability) {
    throw buildUnsupportedImageModelError(imageCapabilities, finalModel);
  }

  const finalResolution = resolution || modelCapability.defaults.resolution;
  const finalN = n == null ? modelCapability.defaults.n : parseImageCount(n);

  if (!modelCapability.limits.resolutions.includes(finalResolution)) {
    throw new Error(
      `Model ${finalModel} does not support resolution ${finalResolution}. Allowed: ${modelCapability.limits.resolutions.join(', ')}`,
    );
  }
  if (finalN < modelCapability.limits.n.min || finalN > modelCapability.limits.n.max) {
    throw new Error(`n must be between ${modelCapability.limits.n.min} and ${modelCapability.limits.n.max}`);
  }
  if (referenceImages.length > modelCapability.limits.maxReferenceImages) {
    throw new Error(`Maximum ${modelCapability.limits.maxReferenceImages} reference images allowed`);
  }

  const estimatedCreditsPerImage =
    modelCapability.pricing && Object.prototype.hasOwnProperty.call(modelCapability.pricing.byResolution, finalResolution)
      ? modelCapability.pricing.byResolution[finalResolution]
      : null;
  const estimatedCredits = estimatedCreditsPerImage == null ? null : estimatedCreditsPerImage * finalN;

  return {
    estimatedCredits,
    selectedModelSummary: summarizeModelSelection(modelCapability, { estimatedCredits }),
    selectionReason: model ? 'Model explicitly provided by caller.' : 'Using backend default model.',
    model: finalModel,
    resolution: finalResolution,
    n: finalN,
  };
}

async function uploadReferenceImages(client, referenceImages, timeoutSec) {
  if (!referenceImages || referenceImages.length === 0) return [];
  const uploadedKeys = [];
  for (const filePath of referenceImages) {
    const key = await client.uploadImageFile(filePath, {
      prefix: 'open-skills/reference-images',
      timeoutSec,
    });
    uploadedKeys.push(key);
  }
  return uploadedKeys;
}

function buildImageResult({
  runId,
  taskId,
  status,
  model,
  resolution,
  n,
  images = null,
  raw = null,
  error = null,
}) {
  return {
    run_id: runId,
    task_id: taskId ? String(taskId) : null,
    status,
    model,
    resolution,
    n,
    images,
    raw,
    error,
  };
}

function persistImageArtifacts(artifacts, result, title = 'Image Generate Result') {
  artifacts.writeJson('outputs/result.json', result);
  const imageLines = (result.images || []).map((img, i) => `- image_${i + 1}: ${img.url || img.object_key || '(pending)'}`);
  artifacts.writeText(
    'outputs/result.md',
    [
      `# ${title}`,
      '',
      `- run_id: \`${result.run_id}\``,
      `- model: \`${result.model || '(unknown)'}\``,
      `- resolution: \`${result.resolution || '(unknown)'}\``,
      `- n: \`${result.n}\``,
      `- status: \`${result.status}\``,
      `- task_id: \`${result.task_id || '(missing)'}\``,
      `- error: ${result.error && result.error.message ? result.error.message : '(none)'}`,
      '',
      ...(imageLines.length ? ['## Images', '', ...imageLines, ''] : []),
    ].join('\n'),
  );
}

// Identical polling logic to generate-video — reuses same task status endpoint
async function pollGenerate(client, taskId, pollInterval = 3, timeoutSec = 300) {
  const startedAt = Date.now();
  let lastStatus = null;

  while (true) {
    if ((Date.now() - startedAt) / 1000 > timeoutSec) {
      throw new Error(`Timeout waiting for task ${taskId}`);
    }

    const statusPayload = await client.getTaskStatus(taskId);
    const status = String(statusPayload.status || '');
    if (status !== lastStatus) {
      console.log(JSON.stringify({ task_id: taskId, status }));
      lastStatus = status;
    }

    if (status === 'succeeded' || status === 'failed') {
      return statusPayload;
    }

    await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
  }
}

function extractImages(raw) {
  if (!raw || !raw.result) return null;
  return Array.isArray(raw.result.images) ? raw.result.images : null;
}

async function runGenerateImageStatus({
  taskId,
  runId,
  skillDir,
  model = null,
  resolution = null,
  n = null,
  wait = false,
  pollInterval = 3,
  timeoutSec = 300,
  client = defaultClient(),
}) {
  const artifacts = artifactsForRun(skillDir, runId);
  artifacts.ensure();

  const raw = wait
    ? await pollGenerate(client, String(taskId), pollInterval, timeoutSec)
    : await client.getTaskStatus(String(taskId));

  const status = String(raw.status || '');
  const images = extractImages(raw);
  const error =
    raw.error && typeof raw.error === 'object'
      ? { message: raw.error.message || String(raw.error) }
      : null;

  const result = buildImageResult({
    runId,
    taskId: String(taskId),
    status,
    model,
    resolution,
    n,
    images,
    raw,
    error,
  });

  persistImageArtifacts(artifacts, result, 'Task Status');

  return {
    runId,
    artifactsDir: artifacts.root,
    taskId: String(taskId),
    status,
    images,
    raw,
    error,
  };
}

async function runGenerateImage({
  prompt,
  runId,
  skillDir,
  model = null,
  resolution = null,
  n = null,
  aspectRatio = null,
  referenceImages = [],
  pollInterval = 3,
  timeoutSec = 300,
  client = defaultClient(),
}) {
  const resolved = await resolveGenerateImageRequest({
    model,
    resolution,
    n,
    referenceImages,
    timeoutSec,
    client,
  });

  const imageObjectKeys = await uploadReferenceImages(client, referenceImages, timeoutSec);

  const artifacts = artifactsForRun(skillDir, runId);
  artifacts.ensure();

  const submit = await client.submitImageTask({
    prompt,
    model: resolved.model,
    resolution: resolved.resolution,
    n: resolved.n,
    aspectRatio,
    ...(imageObjectKeys.length > 0 ? { referenceImages: imageObjectKeys } : {}),
  });
  const taskId = submit.task_id;
  if (!taskId) {
    throw new Error(`Missing task_id: ${JSON.stringify(submit)}`);
  }

  const initial = buildImageResult({
    runId,
    taskId: String(taskId),
    status: String(submit.status || 'submitted'),
    model: resolved.model,
    resolution: resolved.resolution,
    n: resolved.n,
    raw: { submit },
  });
  persistImageArtifacts(artifacts, initial);

  try {
    const raw = await pollGenerate(client, String(taskId), pollInterval, timeoutSec);
    const status = String(raw.status || '');
    const images = extractImages(raw);

    const result = buildImageResult({
      runId,
      taskId: String(taskId),
      status,
      model: resolved.model,
      resolution: resolved.resolution,
      n: resolved.n,
      images,
      raw: { submit, status: raw },
      error:
        raw.error && typeof raw.error === 'object'
          ? { message: raw.error.message || String(raw.error) }
          : null,
    });

    persistImageArtifacts(artifacts, result);

    return {
      runId,
      artifactsDir: artifacts.root,
      taskId: String(taskId),
      status,
      images,
      raw,
    };
  } catch (error) {
    const failed = buildImageResult({
      runId,
      taskId: String(taskId),
      status: String(submit.status || 'submitted'),
      model: resolved.model,
      resolution: resolved.resolution,
      n: resolved.n,
      raw: { submit },
      error: { message: error instanceof Error ? error.message : String(error) },
    });
    persistImageArtifacts(artifacts, failed);
    throw error;
  }
}

module.exports = {
  buildImageResult,
  persistImageArtifacts,
  pollGenerate,
  resolveGenerateImageRequest,
  runGenerateImage,
  runGenerateImageStatus,
};


path: references/common-rules.md
content: # Common Rules

Use these shared rules across CreatOK skills unless a specific skill overrides them.

## Skill Handoff

- When the current skill has already completed its main job and the user's next request more naturally matches a later CreatOK skill, the model should hand off instead of trying to keep everything inside the current skill.
- Do not require the user to say the skill name. Infer intent from natural requests such as "rewrite this for my product", "make me a version like this", "go ahead and generate", or "turn this into a video".
- During handoff, carry forward the already confirmed context so the user does not need to repeat the same brief, script, product details, or direction.

## 401 Unauthorized

- If a CreatOK API call returns `401 Unauthorized`, explain that the user's CreatOK API key is missing or invalid.
- Guide the user to generate a new API key at [https://www.creatok.ai/app/workspace/api-keys](https://www.creatok.ai/app/workspace/api-keys).
- Tell the user to configure `CREATOK_API_KEY` before retrying.
- If the user uses OpenClaw, suggest adding `CREATOK_API_KEY` under the `env` field in `$OPENCLAW_STATE_DIR/openclaw.json`.
- Keep the message practical and non-technical unless the user explicitly needs details for debugging or to share with a developer.

## Repeated Submission Failures

- If task submission fails multiple times in the same conversation or workflow, tell the user their local skills may be outdated.
- Ask them to upgrade skills at [https://www.creatok.ai/agent-skills](https://www.creatok.ai/agent-skills) before retrying.
- Use this guidance for repeated submission failures, not for a single transient network or auth error.


path: scripts/run.js
content: #!/usr/bin/env node
const path = require('node:path');
const readline = require('node:readline/promises');
const { stdin, stdout } = require('node:process');
const { resolveGenerateImageRequest, runGenerateImage, runGenerateImageStatus } = require('../lib/generate-image');

const SKILL_ROOT = path.resolve(__dirname, '..');

function parseArgs(argv) {
  const args = {
    timeoutSec: 300,
    pollInterval: 3,
    yes: false,
    referenceImages: [],
  };

  for (let i = 2; i < argv.length; i += 1) {
    const key = argv[i];
    if (key === '--yes') {
      args.yes = true;
      continue;
    }
    if (key === '--wait') {
      args.wait = true;
      continue;
    }

    const value = argv[i + 1];
    if (key === '--prompt') args.prompt = value;
    if (key === '--task_id') args.taskId = value;
    if (key === '--model') args.model = value;
    if (key === '--resolution') args.resolution = value;
    if (key === '--n') args.n = Number(value);
    if (key === '--aspect_ratio') args.aspectRatio = value;
    if (key === '--reference_images') args.referenceImages = value.split(',').map((s) => s.trim());
    if (key === '--run_id') args.runId = value;
    if (key === '--timeout_sec') args.timeoutSec = Number(value);
    if (key === '--poll_interval') args.pollInterval = Number(value);
    if (key.startsWith('--')) {
      i += 1;
    }
  }
  return args;
}

async function confirmGeneration(args) {
  console.log('About to generate image via CreatOK Open Skills proxy.');
  console.log(`- model: ${args.model}`);
  console.log(`- model_summary: ${args.selectedModelSummary}`);
  console.log(`- model_selection_reason: ${args.selectionReason}`);
  if (args.estimatedCredits != null) console.log(`- estimated_credits: ${args.estimatedCredits}`);
  console.log(`- resolution: ${args.resolution}`);
  console.log(`- n: ${args.n}`);
  if (args.aspectRatio) console.log(`- aspect_ratio: ${args.aspectRatio}`);
  if (args.referenceImages.length > 0) console.log(`- reference_images: ${args.referenceImages.join(', ')}`);
  console.log(`- prompt (first 120 chars): ${String(args.prompt).slice(0, 120)}`);
  const rl = readline.createInterface({ input: stdin, output: stdout });
  try {
    const answer = (await rl.question('Confirm to start generation? (yes/no): ')).trim().toLowerCase();
    return answer === 'y' || answer === 'yes';
  } finally {
    rl.close();
  }
}

async function resolveCommandArgs(args) {
  const resolved = await resolveGenerateImageRequest({
    model: args.model || null,
    resolution: args.resolution || null,
    n: args.n == null ? null : args.n,
    referenceImages: args.referenceImages,
    timeoutSec: args.timeoutSec,
  });

  return {
    ...args,
    model: resolved.model,
    resolution: resolved.resolution,
    n: resolved.n,
    estimatedCredits: resolved.estimatedCredits,
    selectedModelSummary: resolved.selectedModelSummary,
    selectionReason: resolved.selectionReason,
  };
}

async function main() {
  const args = parseArgs(process.argv);
  if (!args.runId || (!args.prompt && !args.taskId)) {
    console.error(
      'Usage: run.js --run_id <run_id> (--prompt <prompt> [--model <model_id>] [--resolution <resolution>] [--n <count>] [--aspect_ratio W:H] [--reference_images /abs/a.png,/abs/b.jpg] [--yes] | --task_id <task_id> [--wait])',
    );
    process.exit(2);
  }

  if (args.taskId) {
    const result = await runGenerateImageStatus({
      taskId: args.taskId,
      runId: args.runId,
      skillDir: SKILL_ROOT,
      model: args.model || null,
      resolution: args.resolution || null,
      n: args.n || null,
      wait: Boolean(args.wait),
      timeoutSec: args.timeoutSec,
      pollInterval: args.pollInterval,
    });

    console.log(
      JSON.stringify({ ok: true, run_id: result.runId, task_id: result.taskId, status: result.status, images: result.images }),
    );
    process.exit(result.status === 'succeeded' ? 0 : 1);
  }

  const resolvedArgs = await resolveCommandArgs(args);

  if (!resolvedArgs.yes) {
    const confirmed = await confirmGeneration(resolvedArgs);
    if (!confirmed) {
      console.log('Canceled.');
      process.exit(2);
    }
  }

  const result = await runGenerateImage({
    prompt: resolvedArgs.prompt,
    runId: resolvedArgs.runId,
    skillDir: SKILL_ROOT,
    model: resolvedArgs.model,
    resolution: resolvedArgs.resolution,
    n: resolvedArgs.n,
    aspectRatio: resolvedArgs.aspectRatio || null,
    referenceImages: resolvedArgs.referenceImages,
    timeoutSec: resolvedArgs.timeoutSec,
    pollInterval: resolvedArgs.pollInterval,
  });

  console.log(JSON.stringify({ ok: true, run_id: result.runId, task_id: result.taskId, images: result.images }));
  process.exit(result.status === 'succeeded' && result.images ? 0 : 1);
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)