OfferUpBot
by yinkev
README.md
# OfferUpBot
OfferUpBot is the OfferUp execution layer for external agents. Agents identify items, choose prices, write copy, and decide responses. OfferUpBot handles OfferUp access, login state, persistence, browser work, retries, duplicate prevention, and external verification.
## Agent guidance
Agents working without conversation history start at `AGENTS.md`. Direct OfferUp execution uses `skills/operating-offerupbot/SKILL.md`; end-to-end physical-item ownership uses `skills/operating-seller-operator/SKILL.md`; consequential image reasoning uses `skills/inspecting-visual-evidence/SKILL.md`. Each skill loads deeper material only when the task requires it.
## Runtime decision
The default interface is **on-demand MCP stdio**:
```text
agent launches OfferUpBot
↓
MCP tools execute against shared SQLite state and browser profile
↓
agent disconnects
↓
OfferUpBot exits
```
State survives in SQLite and the dedicated OfferUp browser profile. The default process does not start a scheduler, HTTP listener, or background monitor.
Continuous `monitor` mode is optional and defaults off. `run:due` performs one explicit catch-up pass and exits.
## Requirements
- macOS with Google Chrome installed for authenticated operations
- Node.js 22 or later
- pnpm
No Docker, external database, Redis, or separately managed runtime service is required.
## Install and build
```bash
git clone https://github.com/yinkev/OfferUpBot.git
cd OfferUpBot
pnpm install
pnpm build
```
Run the development MCP entrypoint:
```bash
pnpm mcp
```
Run the built entrypoint:
```bash
pnpm start
```
The default data directory is:
```text
~/.offerupbot/
├── state.sqlite
├── browser-profile/
└── agents.json # only after restricted agents are created
```
Override it with `OFFERUPBOT_DATA_DIR` when isolation is required.
## OfferUp login
```bash
pnpm auth:login
```
A dedicated Chrome window opens. Enter credentials directly on OfferUp. Login is accepted only when OfferUp's authenticated account query returns a real account ID. Credentials are not requested through chat or stored in SQLite.
## MCP configuration
After `pnpm build`, point the agent host at the built stdio entrypoint:
```json
{
"mcpServers": {
"offerupbot": {
"command": "node",
"args": [
"/absolute/path/to/OfferUpBot/dist/src/index.js"
],
"env": {
"OFFERUPBOT_DATA_DIR": "/absolute/path/to/.offerupbot"
}
}
}
}
```
With no agent token, the local stdio process runs as the trusted full operator.
Create a restricted identity when an agent should receive narrower authority:
```bash
pnpm agent:create -- \
--id research-agent \
--name "Research Agent" \
--permissions market.read,events.read,watch.manage
```
Store the returned token in that agent's MCP environment as `OFFERUPBOT_AGENT_TOKEN`. OfferUpBot stores only its SHA-256 hash.
## Agent tools
| Tool | Purpose | Permission |
|---|---|---|
| `offerup.health` | Local runtime and cached session health | none |
| `offerup.auth_status` | Verify current OfferUp account identity/capabilities | `account.read` |
| `offerup.research_market` | Search with validated OfferUp filters | `market.read` |
| `offerup.inspect_listing` | Full listing evidence; optionally cache ordered photos as readable local files with `download_photos` | `market.read` |
| `offerup.watch_market` | Create, inspect, pause, resume, delete, or run a watch | `watch.manage` |
| `offerup.run_due` | Execute every overdue watch once | `watch.manage` |
| `offerup.next_events` | Read and acknowledge durable events | `events.read` |
| `offerup.sync_listings` | Synchronize active, archived, and saved account state | `account.read` |
| `offerup.sync_inbox` | Synchronize inbox threads and buyer-message changes | `account.read` |
| `offerup.get_thread` | Retrieve one full conversation and listing context | `account.read` |
| `offerup.send_message` | Send one idempotent, externally verified message | `message.send` |
| `offerup.validate_listing` | Validate the current consumer listing contract | none |
| `offerup.publish_listing` | Publish and verify a listing | `listing.publish` |
| `offerup.update_listing` | Edit and verify supported fields | `listing.update` |
| `offerup.close_listing` | Mark sold or archive, then verify account state | `listing.close` |
Start at `skills/operating-offerupbot/SKILL.md`; its adjacent `REFERENCE.md` and `WORKFLOWS.md` hold the detailed contracts and procedures.
## Seller-operator tools
Above the execution tools sits a second MCP surface for agents that own
physical items end to end: 30 `seller.*` tools covering case/item intake,
photos and provenance-tracked evidence, OCR/deterministic extraction and
identity selection, evidence-sufficiency clarifications, comp research,
pricing and disposition decisions, grounded listing drafts with verified
publish/close, buyer classification, offers, exclusive reservations,
appointments (conflict-checked, with idempotent reminders), the due-task
operator loop, handoff recording, and lifecycle analytics.
Permissions: `seller.read` (reads), `seller.write` (domain writes;
publish/close additionally require `listing.publish`/`listing.close`),
`seller.communicate` (buyer ops). Messages still go through the verified
`offerup.send_message`. Start at `skills/operating-seller-operator/SKILL.md`; its adjacent `REFERENCE.md` and `WORKFLOWS.md` hold the evidence, market, buyer, and lifecycle guidance.
## IDs and writes
OfferUp uses two distinct listing identifiers:
- `listing_id`: UUID used for public detail, conversations, verification, and edit mutation input.
- `item_id`: numeric seller-inventory ID used by `/selling`, mark-sold, and archive operations.
Every consequential tool requires a durable idempotency key. Reuse the same key after a timeout or crash. Changing the key can convert a recovery attempt into a duplicate external action.
## Explicit scheduling modes
Run all overdue watches once and exit:
```bash
pnpm run:due
```
Keep watch scheduling alive in the foreground only when continuous polling is deliberately needed:
```bash
pnpm monitor
```
`monitor` is never started by `pnpm mcp`, `pnpm start`, installation, or login. Closing it stops continuous polling. A later `run:due` or MCP `offerup.run_due` call catches up from persisted watch state.
## Handoff surface (opt-in)
`pnpm handoff` starts a small foreground-only HTTP surface for the one human
touchpoint: the in-person handoff. It lists due/pending handoff cards (buyer,
agreed price, appointment, accessories, defects, payment policy, backup
buyers) at `GET /`, exposes `GET /api/handoffs`, and records outcomes via
`POST /api/handoffs/:itemId/result` against the same SQLite state.
It binds `127.0.0.1` (override with `OFFERUPBOT_HANDOFF_HOST`/`OFFERUPBOT_HANDOFF_PORT`)
and requires a bearer token from `OFFERUPBOT_HANDOFF_TOKEN`; when unset, a
token is generated and printed once at startup. Like `monitor`, it is never
started by anything else — the default runtime stays on-demand MCP stdio with
no listener.
## Failure semantics
A write is complete only when its response contains `verified: true`.
Important codes:
- `OFFERUP_SESSION_EXPIRED` / `AUTH_REQUIRED`: complete OfferUp login before authenticated work.
- `RESOURCE_BUSY`: another process owns the account or browser lease; retry later with the same idempotency key.
- `RESOURCE_LOCK_LOST`: do not assume the write completed; call the relevant read or sync tool before retrying.
- `IDEMPOTENCY_CONFLICT`: the same key was presented with different intent; reconcile the calling agent's state.
- `OFFERUP_FILTER_MISMATCH`: OfferUp silently ignored a search constraint; do not use the returned set as valid comps.
- `AUTO_CATEGORY_MISMATCH`: OfferUp enforced a category different from the submitted draft.
- `CATEGORY_PATH_NOT_TERMINAL`: the path stopped on a parent category rather than a selectable final category.
## Verification
Routine verification:
```bash
pnpm test
pnpm typecheck
pnpm build
```
Read-only live acceptance against the configured OfferUp profile and public site:
```bash
OFFERUPBOT_LIVE_TESTS=1 pnpm exec tsx --test \
tests/account/live.test.ts \
tests/auth/live.test.ts \
tests/offerup/public/live.test.ts
```
Safe intercepted write-contract acceptance:
```bash
OFFERUPBOT_LIVE=1 pnpm exec tsx --test --test-concurrency=1 \
tests/messages/live-direct.test.ts \
tests/listings/live-browser.test.ts \
tests/listings/live-close.test.ts
```
The intercepted tests exercise the current message, consumer publish, edit, media-upload, mark-sold, and archive request paths against the live OfferUp UI while fulfilling the consequential mutations locally. They do not create, modify, sell, or archive a real listing.
## Current acceptance boundary
The code, public reads, authenticated reads, current web message mutations, consumer publish/edit UI contracts, and close mutations are implemented and tested. A controlled in-memory agent test covers research through publish, inbox, message, update, and close.
A designated real listing lifecycle was completed on 2026-08-24 against the configured acceptance account: a Philips Norelco BG7030/49 was published with six photos and externally verified, updated from $60 to $55 and re-verified with its `New` condition preserved, then archived and verified in authenticated inventory as `UNLISTED`. The run exposed and closed two live defects: terminal category selection while the outer post dialog remained visible, and condition loss during price-only edits.
The remaining real-message **transport** gate was completed the same day: `offerup.send_message` sent one $50 cash offer through the direct adapter, the returned message ID was reread in the exact thread, and authenticated inbox sync confirmed the conversation. The seller later replied, “Its a pokemon center etb,” exposing a separate reasoning failure. The listing's first photo visibly identified the Pokémon Center exclusive variant, but the agent had valued the standard ETB. Exact TCGplayer product `692949` was near $120, so the $50 offer was about 42% of exact market—not 78%. The exactly-once write acceptance passed; variant identification, valuation, and target selection failed. The corrected postmortem is in `docs/acceptance/2026-08-24-real-message-send.md`.
Both designated consequential-write transport acceptances are complete. Future real writes still require an explicitly designated listing or conversation; repeatable regression tests remain intercepted and must not mutate arbitrary account objects.
## Seller-operator layer (added 2026-08)
Above this execution layer sits the seller-operator system: canonical physical
items with provenance-tracked evidence, identity resolution, market/pricing
research with realized-vs-active comp separation, gated listing orchestration,
buyer/offer/reservation/appointment state, handoff outcome recording, and
reconstructable case context. It is fully wired into the production runtime
(all schemas ensured at startup; `runtime.runDue()` runs one catch-up pass
over watches and seller tasks; account listing syncs append a
views/discussions/price time series) and exposed to agents through the
`seller.*` tools above.
Optional integrations, all off by default:
- `OFFERUPBOT_READ_ONLY=1` — blocks every consequential OfferUp write
(publish/update/close/send, orchestrator path included).
- `OFFERUPBOT_EBAY_CLIENT_ID` / `OFFERUPBOT_EBAY_CLIENT_SECRET` — enable the
eBay comp sources (active asking + sold, official APIs). Without them the
sources report unavailable and comps stay empty; nothing is scraped.
- OCR runs through macOS Vision when available (darwin + osascript) and
degrades to deterministic extraction only elsewhere.
Outcome analytics (`src/analytics`) compute the full metric set over sold and
non-sold items alike, and category priors from completed outcomes feed back
into pricing and disposition (bounded, sample-size shrunk, recorded in the
decision rationale). See `docs/seller-operator-architecture.md` for
the layer map and invariants, `docs/seller-operator-tracker.md` for build
status, and `tests/e2e/seller-operator.test.ts` for the controlled full-lifecycle
acceptance (mocked external writes — no real marketplace mutations).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing