Install
openclaw skills install @nelsonscott/ebay-listerTurn photos of an item into a real, LIVE eBay listing — identifies the item, researches sold-comp pricing, assesses condition, fills the essential listing fields, then drives your logged-in Chrome to eBay, fills the Create-listing form (photos included), and PUBLISHES it. Triggers on /sell, or messages like "sell this on ebay", "list this on ebay", "make me an ebay listing", "what's this worth + list it" — especially when photos are attached. On "List it" eBay validates and the script fills any required field from the payload and retries; if it still can't satisfy a required field it falls back to saving a DRAFT (never publishes junk) and reports what's missing.
openclaw skills install @nelsonscott/ebay-listerPhotos in → a finished eBay listing out. The judgment (identify, price, grade) is yours; the
form-filling is list.js, which drives an already-logged-in Chrome over CDP.
The split of labor:
list.js does the deterministic browser work: connect to the logged-in Chrome over CDP,
open eBay's Create-listing flow, upload the photos, fill the fields you assembled, and click
List it to publish (falling back to a draft only if a required field can't be auto-filled).
It prints PUBLISHED_URL (or PUBLISH_BLOCKED + DRAFT_URL).npm install in this directory (pulls playwright-core).http://127.0.0.1:18800.cp ebay-lister.config.example.json ebay-lister.config.json and edit it — CDP endpoint,
listing defaults (format, starting bid, duration), preferred shipping service, category-id
shortcuts, and an optional notify command. That file is gitignored; with no config file at all
the built-in defaults apply.Only when the user clearly wants to sell/list something — /sell, or natural language like
"list this on ebay" / "sell this" / "make an ebay listing" / "what's this worth, throw it up".
A bare photo with no such ask → do nothing. If photos arrive with /sell or a selling
caption, go.
Photos usually land in a media directory (wherever your chat/upload integration writes them) and also reach you as vision content. Treat all photos in the triggering message as ONE item shot from multiple angles — never split a batch into separate listings.
.heic/.HEIC, convert to JPEG before viewing/uploading
(pip install pillow-heif then read_heif → PIL → save JPEG). eBay accepts JPEG/PNG.Collect the absolute paths of every photo for this item — list.js needs them in order, anchor
first.
Look at ALL photos before saying anything. Extract: brand/manufacturer, model name + number, serial/part numbers, year/generation if determinable, size/capacity/color/material, and any included accessories visible (cables, box, manuals, remotes). If text is only partly visible, say so — don't invent. If you can't confidently ID it, ask one or two targeted questions (or for a full-quality anchor shot) before researching. Don't research on a shaky ID.
Web-search to: confirm the exact model + specs; find recent sold prices (not active asks — sold comps drive price) for the same/comparable item; note the typical sold range and the condition those sold in; flag rarity, demand, discontinuation, or known defects/recalls that move value. If sold data is thin, price off the closest comparable and say so. Don't fabricate a precise number you can't support.
Pick an eBay condition: New / New other / Open box / Used / For parts or not working. For Used, add a sub-grade (Like New / Very Good / Good / Acceptable). List every visible flaw — scratches, dents, scuffs, fading, missing parts, cracks, fraying. Overgrading causes returns and bad feedback; if a surface isn't clearly shown, note it as "verify" rather than assuming clean.
Build a JSON payload list.js consumes. Keep prose tight — buyers skim. Anything you omit that
has a config default (format, startingBid, durationDays, shipping service, categoryId hints) is
filled in from ebay-lister.config.json.
{
"title": "Brand Model Key-Spec Condition (<=80 chars, keyword front-loaded, no L@@K/WOW/emoji)",
"categoryQuery": "short keywords used to find the eBay category, e.g. 'apple iphone 11'",
"categoryId": 139973, // OPTIONAL but recommended — pins the category. 139973 = Video Games (prevents landing in "Cases & Boxes")
"condition": "Used",
"conditionGrade": "Very Good",
"conditionNotes": "Light scuffs on bottom edge; screen clean; no cracks.",
"format": "auction", // omit to use the config default
"durationDays": 7, // OPTIONAL auction length (1/3/5/7/10); omit for the config default
"startingBid": 0.99, // auctions need one; omit for the config default
"price": 110.50, // optional Buy It Now alongside the auction (must be >=30% above startingBid)
"itemSpecifics": { // key/value; fill every one you know
"Brand": "Apple",
"Model": "iPhone 11",
"MPN": "...",
"Color": "Red",
"Storage Capacity": "64 GB"
},
"description": "2-4 short factual paragraphs: what it is + specs; condition incl. wear; what's included; relevant notes (smoke-free etc. ONLY if the seller confirmed they apply).",
"shipping": { "weightLb": 0, "weightOz": 3, "lengthIn": 6, "widthIn": 4, "heightIn": 1 },
"photos": ["/abs/path/anchor.jpg", "...anchor first..."],
"draftId": "1234567890123" // OPTIONAL — same as --draft-id: resume this draft instead of creating a new one
}
Category — pass categoryId when you can. eBay's category step is the one most likely to
stall. Add "categoryId": "<id>" so list.js selects it deterministically, or put a
keyword → id entry in categoryIds in the config and it gets applied automatically. Common ones:
18871 = Cameras & Photo > Memory Cards (SD/microSD), 51071 = USB Flash Drives,
171485 = Cell Phones & Smartphones, 139973 = Video Game Consoles. If you don't know it, omit
it — list.js falls back to "Continue without match", and if that fails it returns a
STUCK_AT=category report (see Step 5) listing the on-screen category radios so you can pick
one and re-run.
Field rules:
conditionNotes/description as "verify".Write the payload to a temp file and run list.js:
# normal flow — publishes the listing
node list.js --payload-file /tmp/ebay-payload.json --mode publish
# resume the draft you already created instead of starting a new one
node list.js --payload-file /tmp/ebay-payload.json --mode publish --draft-id 1234567890123
--dry-run first the very first time (or whenever unsure): it validates the payload,
checks every photo path exists, prints the effective config, and confirms CDP/Chrome
reachability — without touching eBay.--mode publish walks eBay's prelist → listing form, uploads photos, fills the essentials, then
clicks List it. On success it prints PUBLISHED_URL=https://www.ebay.com/itm/<id>.--mode draft clicks Save for later and prints DRAFT_URL=<url>; use it when the seller
wants to review before going live.--draft-id — resume, don't duplicate. Every prelist walk creates a NEW eBay draft, which is
why re-running a blocked listing used to leave a trail of duplicates. list.js prints
DRAFT_ID=<id> on every run: pass it back as --draft-id <id> (or draftId in the payload) and
the script goes straight to that draft's form, skips the prelist entirely, skips re-uploading
photos the draft already has, fills what's changed, and publishes. Any re-run after a
PUBLISH_BLOCKED or a DRAFT_URL should use --draft-id. If the id is wrong or expired it
stops with STUCK_AT=draft-resume rather than silently creating yet another draft.
Format defaults live in the config, not here: format.default (auction or fixed),
format.startingBid, format.durationDays. The shipped default is an auction with a low starting
bid plus an optional Buy It Now price (which must be >=30% above the starting bid).
Fill only the ESSENTIALS — let eBay demand the rest. Put in the payload: title, photos,
condition (+ conditionGrade for graded categories like video games), the price/format fields,
description, shipping, and only the item specifics you actually know. When list.js clicks
"List it", eBay validates and the recovery loop fills any genuinely required field from the
payload and retries. Over-stuffing specifics just burns time on fields eBay doesn't require.
Publish-time recovery (automatic). On --mode publish, list.js loops: List it → if eBay
blocks on a missing required field it reads the inline errors (matching eBay's exact
"The item specific <Aspect> is missing" wording, so it doesn't chase character counters or
field adornments), fills what it can from the payload, and retries (up to 3x). If it still can't
satisfy a required field it has no payload data for, it does NOT publish junk — it saves a
draft and prints PUBLISH_BLOCKED=[...unresolved errors...] followed by DRAFT_URL=<url>.
Surface the specific missing field(s) to the seller, then re-run with --draft-id.
Stuck handoff (never hangs). If list.js can't get past a step it bails within ~8s and prints
a structured report:
STUCK_AT=condition-select | product-match | prelist | draft-resume | form-not-ready
STUCK_URL=... STUCK_SCREENSHOT=.../debug-stuck.png
STUCK_CATEGORIES=[{"value":"139973","label":"Video Games & Consoles > Video Games"}, ...]
STUCK_CONDITIONS=[{"value":"2750","label":"Like New"}, ...] STUCK_BUTTONS=[...]
The prelist auto-navigates category → product-match → condition on its own (and handles the two
condition schemes: standard Used=3000 vs graded Like New=2750/Very Good=4000/...). If it still
stalls on a category ambiguity, read STUCK_CATEGORIES, add the right value to the payload as
categoryId, and re-run — with --draft-id if a draft already exists.
Shipping gotcha: weight must be WHOLE pounds + ounces (weightLb:0, weightOz:3) — a fractional
weightLb like 0.1 silently fails (the "Package size" error). A single run uploads each photo once.
Best Offer follows the config: "bestOffer": "off" (default) forces the toggle off and scrubs
offer language out of the description; "leave" keeps whatever eBay defaults to.
Report back:
PUBLISHED_URL -> ebay.com/itm/<id>) — "it's live".PUBLISH_BLOCKED): it saved a draft instead — give the DRAFT_URL, the DRAFT_ID
to resume with, and the exact missing field(s) that need finishing before it can go live.To push that report somewhere (a chat bot, ntfy, mail), set notifyCommand in
ebay-lister.config.json — list.js pipes the outcome to it on stdin (and as
$EBAY_LISTER_MESSAGE) after a publish, a draft save, or a block. Default is no notification at
all; this skill never messages anyone unless you configure it to.
list.js attaches to an already-running Chrome over CDP (cdpUrl in the config, default
http://127.0.0.1:18800) — so it relies on that Chrome already being logged into eBay. If it
isn't, list.js prints EBAY_NOT_LOGGED_IN; log into eBay once in that Chrome window, then
re-run. No eBay password is stored by this skill.
SKILL.md — this file (the brain + workflow).list.js — CDP/Playwright form-filler. --payload-file, --mode draft|publish, --draft-id,
--dry-run, --debug.ebay-lister.config.example.json — template for your local ebay-lister.config.json.package.json — depends on playwright-core.README.md — engineering notes: the real eBay DOM flow and every gotcha cracked so far.runs.log — append-only record of every fill attempt (created on first run).