← All skills
  • Content
  • v1.0.0
  • product-photos
  • ecommerce
  • gemini
  • images

E-Commerce Photos

Transform raw product images into professional, model-ready photos for eCommerce listings, social media, and marketing materials.

SKILL.md

eCommerce Photos

Turns a seller's raw product photo into new images of the same product: lifestyle scenes, clean studio shots, flat lays, seasonal variations. It uses Gemini image editing with the user's own Gemini API key, through one script that needs nothing installed.

Before you start

  1. The key. You don't need to check it: the script finds the user's Gemini key itself — a key from Settings → Integrations, or one saved in Settings → Secrets (including one added after this machine started). Never use OpenClaw's built-in secrets tool to look for it: vault keys are never stored there, so it always looks empty. Only if the script says No Gemini key found, tell the user: "Add a Google Gemini API key in Settings → Secrets (free to create at aistudio.google.com/apikey)." Never ask them to paste a key into chat.
  2. Find the photo. If the message has an [attachment note: …] with curl commands, run them: they save the attached photos as files. For a photo pasted straight into the chat, look for OpenClaw's copy, newest first: ls -t /root/.openclaw/media/inbound/ | head -5. If there is none, the photo is only visible to you, not on disk: ask the user for a link to it. If the user gave a URL, download it with curl -L -o.

Make an image

mkdir -p /root/.openclaw/workspace/media
python3 /root/.openclaw/skills/ecommerce-photos/scripts/edit_photo.py \
  --input /tmp/upload-1.jpg \
  --prompt "Worn by a woman on a ranch at golden hour, warm light, shallow depth of field" \
  --aspect 1:1 \
  --out /root/.openclaw/workspace/media/hat-ranch-1x1.png
  • --input can be repeated for extra angles of the same product.
  • --aspect: 1:1 Instagram/Facebook feed, 4:5 Instagram portrait, 2:3 Pinterest, 9:16 TikTok/Reels/Stories, 16:9 YouTube and banners.
  • Always write --out under /root/.openclaw/workspace/media/: files outside the workspace may not be delivered to the chat.
  • Prints JSON: {"model": "...", "files": ["/root/.openclaw/workspace/media/..."], "note": "..."}.
  • Exit code 1 means bad input or no key; exit code 2 means Gemini refused or returned no image; the message says why. For a refusal, rephrase the scene (for example, drop a real person's name) and retry once.
  • The script already tells Gemini to keep the product unchanged. Put only the scene in --prompt.

Run one command per image. For three scenes in two sizes, that is six runs; name the files so the user can refer to them (hat-ranch-1x1.png, hat-ranch-9x16.png).

Scene ideas by style

Style Prompt starter
Clean white "On a seamless pure white background with a soft shadow, centered, studio lighting"
Lifestyle "In use by a person in [setting], natural light, candid"
Flat lay "Overhead flat lay on [surface] with [2–3 props that fit the brand]"
Seasonal "In a [holiday/season] setting with [props], cozy, warm tones"
Outdoor "Outdoors at [place] at golden hour, shallow depth of field"

Show before you use

  1. Check every image yourself first. Models sometimes change a logo, text or small detail. If the product no longer matches the original, regenerate it; don't ship it.
  2. Show every image to the user before doing anything else with it: one line MEDIA:<path> per file in your reply, on its own line and not in a code block — for example MEDIA:/root/.openclaw/workspace/media/hat-ranch-1x1.png. The view_image (View Image) tool shows a file only to you, never to the user: use it to check an image, never to deliver one.
  3. Accept edits in plain words ("redo 2 with softer light, no coffee cup") and rerun only that image.

Publishing

This skill makes images; it does not post them. To publish, use the MyClawBots social tools you already have: stage each file with create_upload_url (PUT the bytes, get a public URL), then post_to_social. Always show the user the final captions and where each image goes, and get an explicit yes before posting.

Rules

  • Never alter the product itself: no new colors, logos, text or features.
  • Don't put real, identifiable people in scenes, and don't use other brands' logos.
  • Gemini bills image generation to the user's key, usually a few cents per image. For a large batch (more than 20 images), say so before starting.

Scripts

1 file ship with this skill. They are installed beside the SKILL.md.

scripts/edit_photo.py11.9 KB
#!/usr/bin/env python3
"""
edit_photo.py — turn a product photo into a new image with Gemini.

Stdlib only (no pip installs). Calls Gemini's generateContent REST endpoint
with the product photo inline plus a text instruction, and writes every image
Gemini returns next to --out.

    python3 edit_photo.py --input /tmp/hat.jpg \
        --prompt "Worn outdoors on a ranch at golden hour" \
        --aspect 1:1 --out /root/.openclaw/workspace/media/hat-ranch.png

The key, first found wins (docs/secrets-vault-integrations.md 7.5):
  1. GEMINI_KEY      - the vault surrogate (mcb_sec_...), put in the env at
                       boot: the managed copy of the Settings -> Integrations
                       Google AI key, or a key saved in Settings -> Secrets.
  2. GEMINI_API_KEY  - a real key from Settings -> Integrations, only on agents
                       not yet moved to the vault (legacy). Called directly.
  3. the live vault listing (`vault-curl --secrets`), for a key saved after
     boot: the secret named GEMINI_KEY, else the only secret allowed to reach
     generativelanguage.googleapis.com.
A surrogate is only a stand-in: the call goes through vault-curl, which swaps
in the real key at the api-proxy. The key rides only in the x-goog-api-key
header, and stdout never carries image data.

Exit codes: 0 ok, 1 bad input or no key, 2 Gemini refused or returned no image.
"""
import argparse
import base64
import json
import mimetypes
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request

GEMINI_HOST = "generativelanguage.googleapis.com"
API = "https://" + GEMINI_HOST + "/v1beta/models/{model}:generateContent"
SURROGATE_PREFIX = "mcb_sec_"
# The vault caps a request body at 10 MB; base64 grows the photos by a third.
MAX_VAULT_INPUT_BYTES = 7 * 1024 * 1024

NO_KEY_MSG = ("No Gemini key found. Ask the user to add a Google Gemini API key in "
              "Settings -> Integrations -> Google AI (or in Settings -> Secrets, named "
              "GEMINI_KEY; free to create at aistudio.google.com/apikey). Never ask "
              "them to paste a key into chat.")

# Newest first. A 404 on one (renamed or retired) falls through to the next.
MODELS = [
    "gemini-3.1-flash-image",
    "gemini-3.1-flash-image-preview",
    "gemini-2.5-flash-image",
]

ASPECTS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]

# Appended to every instruction: the one thing a seller cannot accept is a
# product that no longer matches what they actually ship.
FIDELITY = (
    "Use the product in the attached photo exactly as it is: same shape, colors, "
    "materials, logo, text and stitching. Do not redesign, recolor or add branding. "
    "Photorealistic, professional product photography."
)


def fail(code: int, message: str) -> None:
    print(f"ERROR: {message}", file=sys.stderr)
    sys.exit(code)


def call(model: str, key: str, body: dict) -> dict:
    req = urllib.request.Request(
        API.format(model=model),
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json", "x-goog-api-key": key},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=180) as res:
        return json.loads(res.read())


class VaultHTTPError(Exception):
    """A non-2xx answer on the vault path, shaped like urllib's HTTPError."""

    def __init__(self, code: int, detail: str):
        super().__init__(f"HTTP {code}")
        self.code = code
        self.detail = detail


def vault_curl() -> str:
    found = shutil.which("vault-curl")
    if not found:
        fail(1, "this key is a vault surrogate (mcb_sec_...) but vault-curl is not installed "
                "on this machine, so it cannot be used. Tell the user the agent needs a "
                "restart or a newer runtime to use keys from Settings -> Secrets.")
    return found


def vault_listing() -> list:
    """The live vault listing (names, surrogates, hosts; never real keys)."""
    exe = shutil.which("vault-curl")
    if not exe:
        return []
    try:
        out = subprocess.run([exe, "--secrets"], capture_output=True, timeout=30, check=False)
        listing = json.loads(out.stdout.decode("utf-8", "replace") or "{}")
    except (OSError, subprocess.SubprocessError, ValueError):
        return []
    if not isinstance(listing, dict):
        return []
    return [x for x in listing.get("secrets") or [] if isinstance(x, dict)]


def listed_surrogate() -> str:
    """A key saved after boot is not in the env yet: the secret named
    GEMINI_KEY, else the only one allowed to reach the Gemini host."""
    secrets = vault_listing()
    for x in secrets:
        if x.get("name") == "GEMINI_KEY" and x.get("surrogate"):
            return str(x["surrogate"]).strip()
    gemini = [x for x in secrets if GEMINI_HOST in (x.get("allowed_hosts") or []) and x.get("surrogate")]
    if len(gemini) > 1:
        names = ", ".join(sorted(str(x.get("name") or "?") for x in gemini))
        fail(1, f"several saved secrets can reach {GEMINI_HOST} ({names}). Ask the user which "
                "one to use, or to name the Gemini one GEMINI_KEY in Settings -> Secrets.")
    return str(gemini[0]["surrogate"]).strip() if gemini else ""


def resolve_key() -> str:
    # Vault surrogate first; the legacy plaintext key only if there is none.
    for name in ("GEMINI_KEY", "GEMINI_API_KEY"):
        key = os.environ.get(name, "").strip()
        if key:
            return key
    key = listed_surrogate()
    if not key:
        fail(1, NO_KEY_MSG)
    return key


def vault_error(resp_path: str) -> str:
    """One short line from an error body: the vault's reason or Gemini's message."""
    try:
        with open(resp_path, "r", encoding="utf-8", errors="replace") as f:
            body = json.load(f)
    except (OSError, ValueError):
        return ""
    err = body.get("error") if isinstance(body, dict) else None
    if not isinstance(err, dict):
        return ""
    msg = str(err.get("message") or err.get("status") or "")
    reason = err.get("reason")
    return " ".join((f"{reason}: {msg}" if reason else msg).split())[:400]


def call_vault(exe: str, model: str, key: str, body: dict) -> dict:
    """Same request as call(), sent through vault-curl, which swaps the
    surrogate for the real key at the api-proxy. Request and response live in
    a temp dir, so image data never reaches stdout or argv."""
    tmp = tempfile.mkdtemp(prefix="edit-photo-")
    try:
        req_path = os.path.join(tmp, "req.json")
        resp_path = os.path.join(tmp, "resp.json")
        with open(req_path, "w", encoding="utf-8") as f:
            json.dump(body, f)
        # The surrogate goes ONLY in the x-goog-api-key header; anywhere else
        # the vault refuses it (surrogate_not_allowed_here).
        run = [exe, "-sS", "-X", "POST", API.format(model=model),
               "-H", f"x-goog-api-key: {key}",
               "-H", "Content-Type: application/json",
               "--data-binary", f"@{req_path}",
               "-o", resp_path,
               "-w", "%{http_code}",
               "--max-time", "180"]
        try:
            proc = subprocess.run(run, capture_output=True, text=True, timeout=200)
        except subprocess.TimeoutExpired:
            fail(2, "the request to Gemini timed out")
        status = (proc.stdout or "").strip()[-3:]
        if not status.isdigit() or status == "000":
            lines = (proc.stderr or "").strip().splitlines()
            detail = " ".join(lines[-1].split())[:300] if lines else "no response"
            fail(2, f"could not reach Gemini through the vault: {detail.replace(key, '<surrogate>')}")
        if not status.startswith("2"):
            raise VaultHTTPError(int(status), vault_error(resp_path).replace(key, "<surrogate>"))
        try:
            with open(resp_path, "r", encoding="utf-8") as f:
                data = json.load(f)
        except (OSError, ValueError):
            fail(2, f"HTTP {status} from Gemini, but the response was not JSON")
        return data if isinstance(data, dict) else {}
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def main() -> None:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument("--input", required=True, action="append",
                   help="product photo (repeat for extra angles of the same product)")
    p.add_argument("--prompt", required=True, help="the scene or edit to make")
    p.add_argument("--aspect", default="1:1", choices=ASPECTS)
    p.add_argument("--out", required=True, help="output path, e.g. /root/.openclaw/workspace/media/hat-ranch.png (under the workspace, so MEDIA: lines can deliver it)")
    p.add_argument("--model", help="force one model id instead of the fallback list")
    a = p.parse_args()

    key = resolve_key()
    via_vault = key.startswith(SURROGATE_PREFIX)
    exe = vault_curl() if via_vault else ""

    parts = []
    for path in a.input:
        f = pathlib.Path(path)
        if not f.is_file():
            fail(1, f"input not found: {path}")
        mime = mimetypes.guess_type(f.name)[0] or "image/jpeg"
        if not mime.startswith("image/"):
            fail(1, f"not an image: {path}")
        parts.append({"inlineData": {"mimeType": mime, "data": base64.b64encode(f.read_bytes()).decode()}})
    if via_vault and sum(pathlib.Path(x).stat().st_size for x in a.input) > MAX_VAULT_INPUT_BYTES:
        fail(1, "input photos total more than 7 MB; resize them or send fewer")
    parts.append({"text": f"{a.prompt.strip()}\n\n{FIDELITY}"})

    body = {
        "contents": [{"role": "user", "parts": parts}],
        "generationConfig": {
            "responseModalities": ["TEXT", "IMAGE"],
            "imageConfig": {"aspectRatio": a.aspect},
        },
    }

    data, used, last_error = None, None, ""
    for model in ([a.model] if a.model else MODELS):
        if via_vault:
            try:
                data, used = call_vault(exe, model, key, body), model
                break
            except VaultHTTPError as e:
                last_error = f"{model}: HTTP {e.code} {e.detail}".strip()
                if e.code == 404:
                    continue  # model id not available; try the next one
                fail(2, last_error)
        try:
            data, used = call(model, key, body), model
            break
        except urllib.error.HTTPError as e:
            detail = e.read().decode(errors="replace")[:400]
            last_error = f"{model}: HTTP {e.code} {detail}"
            if e.code == 404:
                continue  # model id not available; try the next one
            fail(2, last_error)
        except urllib.error.URLError as e:
            fail(2, f"could not reach Gemini: {e.reason}")
    if data is None:
        fail(2, f"no image model available. Last error: {last_error}")

    out = pathlib.Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    written, notes = [], []
    for cand in data.get("candidates", []):
        for part in (cand.get("content") or {}).get("parts", []):
            blob = part.get("inlineData") or part.get("inline_data")
            if blob and blob.get("data"):
                ext = mimetypes.guess_extension(blob.get("mimeType") or blob.get("mime_type") or "") or ".png"
                target = out if not written else out.with_name(f"{out.stem}-{len(written) + 1}{out.suffix or ext}")
                if not target.suffix:
                    target = target.with_suffix(ext)
                target.write_bytes(base64.b64decode(blob["data"]))
                written.append(str(target))
            elif part.get("text"):
                notes.append(part["text"].strip())

    if not written:
        reason = (data.get("promptFeedback") or {}).get("blockReason") \
            or next((c.get("finishReason") for c in data.get("candidates", []) if c.get("finishReason")), "")
        fail(2, f"Gemini returned no image (model {used}). {reason} {' '.join(notes)}".strip())

    print(json.dumps({"model": used, "files": written, "note": " ".join(notes)[:500]}))


if __name__ == "__main__":
    main()