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.
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.
Six steps, one human in the loop.
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.
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.
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.
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.
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.
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.
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.
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.
It runs against a real account, with real buyers.
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.