← ProjectsAutomation

eBay Listing
Pipeline

Photos in, AI-drafted listings out, published through the eBay Sell API. Claude reads the photos, drafts the listing, and prices it. The code decides what is allowed to publish. I decide what does.

The problem

Listing is the bottleneck, not sourcing.

Buying a pallet takes an afternoon. Listing it takes weeks. Every item needs identification, a title that actually matches how people search, a description, condition notes, a category, a price, a shipping choice, and a margin check — and none of that work is interesting the second time you do it.

So I built the boring half away. The pipeline takes the one input only a human can provide, photographs of the item in hand, and carries it the rest of the way to a live listing — stopping exactly once, for the one decision I want to keep.

How it works

Six steps, one human in the loop.

  1. Intake

    Drop photos into a Discord channel from a phone. Discord is the interface because it is already in my pocket, standing in the garage next to the pallet. A caption is optional context — "82mm filter, micro abrasions on the glass, has case" — and every job gets its own thread. Where the item came from and whether it has been tested are owner-supplied facts, typed once in that caption; the model is told them and forbidden from inventing them.

  2. Identification and drafting

    One vision call reads every photo and returns a complete draft as validated JSON: a search-shaped title, item specifics, a description in a consistent voice, a condition with every visible flaw disclosed, a price with its reasoning, and a packed-weight estimate. If the model is not sure what it is looking at, it asks instead of guessing a model number. Every discount in the price reasoning has to name a live driver — liquidation origin, untested, a specific flaw in the photos — and a discount with no driver fails the draft and retries rather than being quietly edited.

  3. Economics

    Before anything can publish, the draft carries the numbers: gross, eBay fees, shipping, unit cost, net, and margin, per unit and extended when the quantity is more than one. Unit cost comes from a manifest import — spreadsheet, PDF, or a photo of the packing list — allocated across the pallet by retail share, or from a cost typed in the caption, including consignment splits. A listing that would net less than a floor I set is blocked, not warned about.

  4. Comps and shipping

    Active comparable listings are pulled through the eBay Browse API on every draft and the median, spread, and count sit in the embed next to the drafted price, with a link to sold results. The weight and size estimate drives a suggested fulfillment policy, offered as a dropdown of the handful I actually use, ranked by how often I have picked each one, rather than the seventy-odd the account has accumulated.

  5. Review

    The draft comes back into the same thread with buttons, a menu of condition grades, slash commands, and a "Needs your attention" list naming every blocker next to the control that clears it — or I just type "it’s new", "paid 5", "ship it priority", and the bot echoes what it understood before acting, with undo. Every button submits exactly what its typed equivalent would, so there is one code path and one set of gates, however the input arrived.

  6. Publish

    A single ✅ from the owner account publishes through the eBay Sell API — inventory item, offer, publish — with photos uploaded and the category and specifics already mapped. The thread is renamed to the listing and its id; a restart mid-publish reconciles against eBay by SKU rather than retrying blind. Nothing publishes on a timeout, a rule, or a reply that merely sounds like approval.

Guardrails

Automate the draft. Never the judgment.

It is the same architecture as the day job at hobby scale: a system of record, an integration layer, and an approval step where a person stays in the loop on anything that is hard to undo. Automating the draft is cheap. Automating the publish button without review is how you end up with a hundred wrong listings and a suspended account. So the interesting engineering is not the model call — it is everything that stands between the model and the publish button.

  • Rule

    The publish gate reads one list

    A single function computes every blocker on a draft — unclassified defect text, no cost basis, no shipping policy, a probable duplicate of something already listed, a placeholder left in the title — and that same list renders the "Needs your attention" section and refuses the publish. What I am told is blocking me is, by construction, what is actually blocking me. A test mutates the list and asserts both follow.

  • Rule

    Condition is disclosed, not softened

    A defect the model found in the photos has to appear in the description and rules out a "new" grade; acknowledging it is not the same as disclosing it. Benign return reasons are exempt, cosmetic wear has its own tier, and the gate is tuned to over-refuse rather than under-refuse. The listing copy is honest because the code will not let it be otherwise.

  • Rule

    Provenance is asserted, never inferred

    The drafter used to assume every item was an untested pallet pull with unknown history, and that assumption leaked into the copy and the price. Origin and test status are now structured facts only I can set, and they decide which disclosure the description carries. Serial numbers go the other way: stored for my records, scrubbed from the caption before the model ever sees them, and scrubbed again by exact value at every output — a placeholder left behind is itself a blocker, because a title with a hole in it needs a better title, not a second press of the button.

  • Rule

    Prose never carries authority

    Natural-language replies go through a small model that maps them onto the same fields the typed commands set — but the schema forbids unknown keys, confidence is clamped, and there is no publish field to return. A reply that asks to publish, skip, or override is refused as a correction, whatever the parser said, and its words are not forwarded to the drafter either. Three commands deliberately have no spoken twin: the margin override, undo, and anything that spends money on an API call.

  • Rule

    Sandbox and production cannot touch

    Two separate stacks with their own image, container, volume, and environment file. Each declares which environment it is, and the bot refuses to start if the configuration it loaded disagrees; slash commands answer only in their own stack’s channels. The startup banner and the Discord presence both say SANDBOX when it is not production, because the expensive mistake — believing you are in the sandbox while publishing live — is silent by nature.

  • Rule

    Secrets never cross into the container as a file

    Credentials are injected as environment variables from a locked-down file on the host. Every outbound Discord message and every alert passes through a chokepoint that redacts tokens, and a structural test reads the bot source and fails on any direct send that bypasses it. Exceptions carry documented fields only, never a response body, and a failed credential is told apart from a network blip so the alert names the right remedy.

Operating it

Built to be run, not just demoed.

  • Runs as a non-root container on the homelab, deployed by a script that proves what it started: it hashes the running code against the checkout and refuses to report success on a mismatch.
  • Alerts on every error to an ops channel, rate-limited per source so a hot loop cannot flood it and an unrelated second failure is not masked by the first. Failures that stop listings name their remedy.
  • A daily heartbeat that also counts down the eBay refresh token’s expiry, and a container healthcheck on that heartbeat file — the only thing that makes a wedged event loop visible when the process itself stays up.
  • Per-job locks cover decisions, never network calls, and a watchdog alerts on any lock held too long — a job that goes silent is an outage, even when every state on disk looks healthy.
  • SQLite backups taken with a proper online snapshot, integrity-checked, and pruned by a host cron job — not scheduled inside the process being backed up, which stops for the same reason the process did.
  • A spec that is the source of truth, updated before code. Every feature goes through adversarial review by a second model before it merges, and every round so far has found real defects.
  • Automated tests run in CI inside the same image that deploys, as the same unprivileged user, with no network, no clock, and no Discord gateway — the suite fakes all three.
Language
Python 3.12, asyncio
Interface
Discord bot — buttons, modals, slash commands, and plain replies
Models
Claude — a Sonnet-class model for vision and drafting, a smaller one to parse replies; provider-swappable by config
eBay
Sell Inventory, Account, Taxonomy, and Browse APIs; Trading API for photos
Storage
SQLite for pricing, pallets, and per-listing economics; job state on a Docker volume
Runtime
Docker on the homelab, separate sandbox and production stacks
Built
Spec in July 2026, first sandbox publish six days later, production in September; v1.3 as of this writing
Status
Deployed to production against the real seller account

GITHUB ↗

Proving ground

It runs against a real account, with real buyers.

365Items sold through the account
2006Member since

Two decades of buying and moving computer hardware is what produced the throughput problem in the first place. Sourcing was never the constraint — listing was. That is a specific, measurable bottleneck, and it is the reason this pipeline exists rather than being a weekend exercise against a sandbox.

It also means the system gets tested the honest way: a real seller account, real inventory, and buyers who will say immediately when a title is wrong or a condition note was too generous. There is no environment where a bad listing is harmless — which is precisely why the approval step is not optional, and why the sandbox stack exists to catch everything else first.