Status: draft agreed in the design interview on 2026-09-14. Vocabulary in ../CONTEXT.md.
Endpoint facts come from live read-only probes on that date; the API is unofficial and may drift.
Binary wallapop. Module github.com/Microck/wallapop-cli. Go 1.27, single static binary.
Search, track and chat on Wallapop from the terminal, with your own account.
wallapop [global flags] <noun> <verb> [args] [flags]
Noun-verb throughout. No catch-all subcommand, no abbreviations.
auth login | status | refresh | logout
profile list | use | remove
search [keywords...] | filters
category list
alert list
item show | open | favorite | unfavorite | reserve | reactivate | sold | delete
user show | items | reviews
me show | items | favorites
chat list | show | send | start | open | archive
watch add (search|item|seller) | list | remove | check | run | events | service (install|uninstall|status)
sink list | test
mcp (no verb: serves) | install claude-code|codex|cursor
config path | get | set | list
doctor
skills list | get
completion <shell>
auth login [--profile NAME] [--cookies FILE | --cookies-stdin] [--cookies-format auto|netscape|json|header] [--service | --no-service]Default is the cookie import prompt: it asks for a cookie export or the pasted value of__Secure-next-auth.session-token. Exports are parsed by Galleton'scookiefilepackage: Netscapecookies.txt, Cookie-Editor/EditThisCookie JSON (array or{"url", "cookies"}wrapper), DevTools/Playwright cookie lists, andCookie:headers; a bare session value is also accepted. Only cookies for the Wallapop web and API hosts are read, so a whole-browser export cannot supply another site's NextAuth cookie. It reads that cookie plusdevice_id, mints an Access token once to validate, stores the Session, and seeds the Profile's default Location fromGET /api/v3/users/me. Every login method (password, Google, Apple, Facebook) ends in the same browser cookie, so this is the only path. Email+password login (POST /api/v3/access/login) is not implemented: the flow was not verified and cookie import covers every account. Implicitly creates the Profile; the first Profile becomes default. The Session recordssession_expires, the imported cookie's expiry (not exposed byauth statusfor managed sessions; only the renewal warning reads it), andsession_issued.session_issuedis the import time, and when the login's own mint,auth status --check,auth refreshordoctorsee a later engine renewal (last_refresh), the expiry moves to that renewal plus the recorded lifetime, at least 30 days, since each mint re-issues the cookie with a fresh 30-day expiry. After a successful login, when stdin and stderr are terminals,--no-inputis not set,CIis nottrue,WALLAPOP_SESSION_TOKENandWALLAPOP_GALLETON_*are unset, and the renewal service is not installed yet, it asks on stderrkeep this login alive in the background? [Y/n]. Yes or an empty line runs theauth service enablecode path and reports the unit on stderr; end of input counts as no; no printsrun `wallapop auth service enable` later to turn it on.--serviceanswers yes without asking and always runs the enable again, which retries a half-failed one (works with--no-input; a failure is exit 1 with the login kept),--no-serviceanswers no; the two are exclusive (exit 2). The status is printed on stdout after the choice.auth statusshows locally stored profile/account metadata and the session source;--checkobtains a usable token and reportssession_valid. Managed cookie expiry is omitted because the daemon API does not expose it. When the imported cookie expires within 48 h and the renewal service is not installed, it adds awarning(also on stderr; never withWALLAPOP_GALLETON_*overrides, whose owner runs the engine); the exit code is unchanged.auth refreshexplicitly requests renewal and prints status. A failed or paused renewal fails the command; provider expiry/revocation can require another browser import.auth logout [--yes]forgets the daemon session before deleting the local reference. Failed deletion preserves the reference for retry. This does not revoke the browser login.auth service runkeeps renewal in the foreground.enableexplicitly installs a user OS-login service/task,disableremoves it,statusinspects without starting it, andstopdrains an idle engine. Active clients prevent shutdown. No service is installed without consent: onlyenable, or a yes to the login prompt or--service.statusalso reports, from a running engine,proxied,paused_until, andproxy_mismatchwhen it runs with another proxy setting.
Session mechanics: a pinned Galleton v0.1.1 executable is included in release binaries and
managed by the CLI. It renews through the existing GET /api/auth/session endpoint, extracts
/token, requires a replacement cookie, and persists rotated credentials in its encrypted
vault. The provider adapter's interval is 240 seconds, not a promise about token lifetime.
Its block_signatures turn a CloudFront 403 page, or any non-JSON 403, during renewal into a
temporary refusal (upstream_blocked, exit 5) that keeps the session; min_interval_ms
spaces renewals one second apart. When a proxy is set, the adapter gets
proxy_env: WALLAPOP_PROXY and the engine gets the URL in its environment only; a running
engine with a different proxy setting is refused, not shared. These fields are outside
Galleton's configuration hash, so sessions imported by v0.3.x keep working. Only the bearer
token is handed to the existing API transport. Uploads and PubNub keep separate transports.
When a request of the CLI's own meets Wallapop's bot protection (a non-JSON 403 after the
browser user agent retry, or a 429), the CLI pauses the engine it already uses for 15
minutes through Galleton's persisted provider pause. Paused renewals fail with
provider_paused and the pause's end time (exit 5); a held token keeps working.
Session lifetime remains controlled by Wallapop. The historical sliding-cookie behavior recorded in ADR 0001 does not establish indefinite validity. No renewal is automatically replayed after an ambiguous failure. Watch checks obtain a token when a stored session exists; failure is a warning so public Watch checks can still run.
Legacy cookie profiles migrate on first authenticated use. After obtaining a usable token,
the CLI atomically replaces the local cookie with a stable galleton_id. Concurrent saves
preserve other profiles; stale saves cannot resurrect a removed profile. An interrupted
migration adopts the existing daemon session instead of overwriting its rotated cookie.
WALLAPOP_SESSION_TOKEN remains a non-persistent explicit environment override; it is not
silently migrated. See the lifecycle guide and ADR 0003.
profile list(name, account, default marker, session expiry)profile use NAMEsets the defaultprofile remove NAME [--yes]removes session and its Watches
Selection order: --profile > WALLAPOP_PROFILE > default in config.
search [keywords...] [--lat F --lng F] [--distance KM] [--min-price N --max-price N] [--condition new|as_good_as_new|good|fair|has_given_it_all]... [--category ID] [--shipping] [--since today|week|month] [--sort relevance|newest|price_asc|price_desc] [--filter key=value]... [--limit N] [--pages N] [--next TOKEN]HitsGET /api/v3/search. Location defaults to the Profile's, then configlocation. Without a Profile and without--lat/--lng, exit 2 with a hint.--filteris validated againstGET /api/v3/search/filters/regular-filtersfor the resolved category; unknown keys exit 2 listing the valid ones. JSON output carriesnext_pagefor continuation.search filters [--category ID] [keywords...]prints the filters Wallapop offers for that category (id, type, allowed values). Source of truth for--filter.
category list [--tree]fromGET /api/v3/categories.
Wallapop's server-side Saved searches (CONTEXT.md), read-only: the CLI never creates, enables or deletes them.
alert listfromGET /api/v3/searchalerts/savedsearch/(authenticated, needsX-AppVersion: 0or the service answers 400). Each entry: id (UUID), title, keywords,location_label, Location (lat/lng), radius, Filters (the stored query minus location and bookkeeping, with Wallapop's own keys:max_sale_price,order_by,category_id...), alert enabled, new hits, created.category_idsis stored as a list and replayed as the first element undercategory_id, as the web does; other lists join with commas like the CLI's own multi-value flags.watch add search --from-alert ID --name NAMEreadsGET .../savedsearch/{id}and stores a search Watch with that exact query, so its first Check returns what the web shows when the alert is opened. Unknown id:{"status":"NOT_FOUND"}404, exit 4. Keywords or search flags alongside--from-alertare a usage error. Verified live 2026-09-15.
Item argument accepts a 12-char hash or an es.wallapop.com/item/... URL. Bare numeric ids
are rejected with a hint: the item page needs the slug, and Wallapop has no numeric-id route.
URLs resolve to the hash through the item page's __NEXT_DATA__; hashes combine the API
detail with the page flags.
- The item detail endpoint is the API of record: title, description, condition and price come from it, empty and zero included, and the rendered page contributes only what it alone knows (flags, counters, slug and the full taxonomy path). The page is a cached snapshot and lags writes. Both reference forms take the same merge, so a hash and its URL always report the same listing.
item show ITEMmergesGET /api/v3/items/{hash}with the page flags (reserved, sold, expired, modified date, views, favorites).item images ITEMreturns the direct image URLs in gallery order, using the same item read asitem show. JSON is an array of URL strings; JSONL emits one string per line; pretty emits one unadorned URL per line. No images returns an empty array (or no lines in JSONL/pretty).item show --format prettyalso lists each image URL without truncation. These commands read metadata, not image bytes.item open ITEMopens the web page in the browser.item favorite ITEM/item unfavorite ITEMviaPUT /api/v3/items/{hash}/favorite.item reserve ITEM [--off],item sold ITEM [--yes],item delete ITEM [--yes]act on the account's own Items:PUT /api/v3/items/{hash}/reserve {reserved},PUT .../sold,DELETE /api/v3/items/{hash}, all 204 withAuthorizationandX-DeviceOS: 0alone.soldanddeleteprompt in a TTY and require--yesotherwise. The CLI fetches the Item first and refuses one listed by another account (usage error, exit 2) because Wallapop answers such writes with a bare 401 (reserve, delete) or 403 (sold); should such a status still come back (ownership changed between check and write), the API package reports it as a refused write, exit 1, not as a dead session. Verified live 2026-09-15 on a throwaway listing: reserve, unreserve, sold (moves tome items --sold) and delete (page and API 404 afterwards), each reflected on the web.item reactivate ITEMrelists one of the account's own expired Items withPUT /api/v3/items/{hash}/reactivate {}(204, the web's "Reactivar"). Expired Items 404 on the detail and page, so ownership is checked againstGET /api/v3/user/items(expired.flag): not found is exit 2 with no write, not expired printsalready active. Verified live 2026-10-24.item create --title T --description D --price P --category LEAF --condition C --image F... [--attr k=v...]publishes with images:POST /api/v3/items/upload/componentsopens a client-UUID upload session, each picture goes toPOST /api/v3/upload/{uuid}/pictures(multipartimagepart, 204), thenPOST /api/v3/itemssends multipart (the firstimageplus anitemJSON part: attributes,category_leaf_idas string, location,upload_id) withAccept: application/vnd.upload-v2+json(application/jsonanswers 405), returning 200{"id"}. Remaining images go toPOST /api/v3/items/{id}/picture2with one multipartimageand anorderfield starting at 1. Failed gallery uploads warn with the item id and picture number while returning the published item successfully. Once that id comes back the listing exists, so a failure to read it straight back is reported as a notice over the listing as sent, never as a failed create: exiting non-zero there would invite a retry that publishes twice.--categoryresolves againstGET /api/v3/categories?context=create(leaf id or name);--attrkeys validate against that leaf's inherited attribute list and are sent under the category's own spelling; title, description, condition and price_amount are refused there, since they have typed flags; a missing title, description, price, category, condition or image is a usage error (exit 2), as is--latwithout--lngor the reverse. Verified live 2026-09-15: Books leaf 24097 published with photo, page 200, then edited, image-replaced, deleted (old page 404).item edit ITEM [--title T] [--description D] [--price P] [--condition C] [--category LEAF] [--attr k=v...] [--image F...]overlays only the flags passed onto the account's own listing (PUT /api/v3/items/{hash}, same multipart shape, JSON body when no images) and refuses foreign listings (exit 2). The write replaces the whole attribute set, so the listing's existing category attributes go back untouched under the flags passed. It also resends the leaf category: a single-level taxonomy is its own leaf, a nested path is matched back against the create tree (detail APIs expose the path, not the leaf id), and a nested path that will not resolve is an error rather than a guess, since a root id sent as the leaf recategorises the listing. An explicit positive--categorypins the leaf and controls attribute validation. Image replacement sends the first image in the edit, then the rest throughpicture2in order; later upload failures warn and return the edited item. Location is resent unchanged and omitted when the item carries none. No change flags is a usage error (exit 2). As with create, once the write lands a failed read-back is a notice over the listing with the overlay applied, not a failed edit. Verified live 2026-09-15 on the throwaway above: title+price edit, picture replacement, then delete; one older sofa listing (vjrewv00elzk) remained.
user show USER,user items USER,user reviews USER(public endpoints; USER is id or slug).me show,me items [--sold],me favorites(authenticated).GET /api/v3/user/items[/sold]carries no location, some itemsshows an empty city (observed 2026-09-15).
Conversation argument: conversation hash, unique prefix of it, or an Item hash the account already has a Conversation about. Text chat endpoints were exercised live against the owner's test account on 2026-09-14; offer send, decline and receive were recorded on 2026-09-16.
-
chat list [--unread] [--archived] [--limit N]fromGET /bff/messaging/inbox(page_size,max_messages,from=<next_from>) orGET /bff/messaging/archived. -
chat show CONV [--limit N]fromGET /bff/messaging/conversation/{hash}(last 30 Messages embedded), older pages viaGET /api/v3/instant-messaging/archive/conversation/{hash}/messages?max_messages=30&from=. Oldest first. Marks the Conversation read with a PubNub message actionseenon the last Message, unless--no-mark-read. -
chat send CONV TEXT(-reads TEXT from stdin). PubNub publish on the Conversation'schannelwithmessage={id,payload:{text}}andmeta={type:"text",sender:{platform:{app_version,os_version}},to_user_hash,from_user_hash,conversation_hash}, auth token fromGET /api/v3/instant-messaging/token(PAM v3, grantsinbox.<me>,signals.<me>,chat.<me>.*.*,chat.*.*.<me>). The channel string is opaque; never build it. -
chat start ITEM TEXT:POST /api/v3/instant-messaging/conversation {item_hash_id}(bundle) returns{conversation_id, channel}; error code 100 means Wallapop's cap on new conversations. Thenchat send. If a Conversation for that Item already exists in the inbox, reuse it instead of posting. -
chat open CONVline-mode REPL: prints history, then long-polls PubNub subscribe oninbox.<me>and prints Messages whoseconversation_hashmatches, while reading lines from stdin to send./quitor Ctrl-D exits. No TUI. Messages arriving during the session are marked seen as they land, the way opening the Conversation marks what was already there.Text message inbox delivery is a forwarded copy, recorded live 2026-09-16 between the owner's two accounts. Its envelope carries
u.original_time_tokenandu.original_channel, which are the Message's identity on its own Conversation channel, and the envelope's ownp.tis a few milliseconds later. The REST Message list and theseen/receivedactions both key off the original, so that is what the CLI stores and what a receipt is posted against; using the inbox copy's timetoken addresses a Message that exists nowhere else.The same channel also carries message actions:
e: 3,source: "actions", nou, ad.dataholdingtype(seenorreceived),messageTimetokenand a JSONvalue. These are receipts, not Messages, and are skipped. The account's own Messages are echoed back here too, flagged byfrom_user_hash, andchat opendrops them since it already printed what it sent.The PubNub Access Manager token's real TTL is 60 minutes (
ttl: 60in its CBOR body). The CLI keeps a conservative 10-minute local guess and refreshes ahead of it, so a long session rotates the token roughly six times per real lifetime. Verified live 2026-09-16: a session open for 18 minutes refreshed at 11 minutes and kept receiving afterwards. -
chat archive CONV [--undo]:PUT /api/v3/instant-messaging/conversations/archive{conversation_ids:[hash]}(bundle),unarchivefor--undo. 409 counts as success. -
chat offer CONV AMOUNT: sends one buyer offer in the Item's currency. AMOUNT is a positive decimal with at most two fractional digits. The CLI readsGET /bff/delivery/make-an-offer?item_id=HASHand rejects an exhausted daily allowance, a price below the reported floor, a price above asking, or the caller's own Item with exit 2 before any offer write. The floor is asking price lessmax_offer_percentage, rounded to cents. A missing restriction or price is an API-changed error, not permission to send.POST /api/v3/delivery/buyer/offerssends{offer_id:<client UUID>,offer_price_amount,offer_price_currency,item_ids:[HASH]}and answers 201 with an empty body. -
chat offer decline CONV: seller-only. Find the newest linked offer, paging older Messages when necessary, refresh its status card, verify its Item and Buyer match the Conversation, and requirePENDING. Never fall back to an older pending offer when the latest is terminal.PATCH /api/v3/delivery/offers/{offer_id}/statuswith{"status":"DECLINED"}answers 200 with an empty body. No accept or counter-offer command exists.
Delivery calls require bearer auth, X-DeviceOS: 0 and X-AppVersion: 0. The terms read on
the owner's EUR 1 Item returned the following relevant fields. Names and identifiers in
the examples and fixtures are sanitized; omitted card UI fields are not used by the CLI.
{"restriction":{"max_offers_per_day":10,"remaining_offers_per_day":8,"max_offer_percentage":30},"item":{"title":"Test item","price":{"amount":1.00,"currency":"EUR"}},"items_price":{"amount":1.00,"currency":"EUR"},"offer":null,"suggested_offers":[]}This means a EUR 0.70 minimum, not a 30-percent-of-asking minimum. A new EUR 0.85 offer was
sent by the owner's buyer Profile, received on the seller's PubNub subscription, then
declined by the owner's seller Profile. The prior pending EUR 0.90 test offer was declined
first. The writes returned 201 and 200 respectively. A subsequent buyer-side chat show --no-mark-read displayed EUR 0.85 as declined. Neither accept nor payment was called.
Offers arrive with PubNub u.type: "server-message", not a new offer meta type:
{"u":{"type":"server-message","date":1789593225517,"to_user_hash":"SELLER","from_user_hash":"BUYER","conversation_hash":"CONV"},"d":{"id":"MESSAGE_UUID","payload":{"payload":"{\"text\":\"Buyer ha hecho una oferta de 0,85€.\",\"type\":\"delivery\",\"actionURL\":\"/delivery/CONV\",\"buttons\":[{\"loc-key\":\"chat_buyer_all_all_third_voice_button\",\"loc-value\":\"Ver\",\"action\":\"https://wallapop.com/app/chat/offer/11111111-1111-4111-8111-111111111111\"}],\"enrichedPayload\":null}"}}}The decline payload has the same shape and link, with text Has rechazado la oferta..
These two observed envelopes had no original_time_token or original_channel;
the CLI displays them but does not invent a channel for a live read receipt. Text Messages
continue to use their original identity. The complete sanitized envelopes are in
internal/fakewallapop/testdata/offer-events.json, served by the fake in regression cases.
The messaging BFF uses type: "delivery_generic", with the inner JSON string directly in
payload; it also copies the localized sentence into text. The button link carries the
offer UUID. GET /bff/delivery/offer-details?offer_id=UUID supplies machine-readable values:
{"offer_status":{"title":"You received an offer!"},"offer_analytics":{"item_id":"ITEM","offer_id":"11111111-1111-4111-8111-111111111111","offer_price":{"amount":0.85,"currency":"EUR"},"offer_status":"PENDING","buyer_user_id":"BUYER"}}After decline, offer_status in offer_analytics is DECLINED. List, show and live output
attach this current card as message.offer with amount, currency and status;
older Messages about the same offer therefore show its current state, not a reconstructed
historical state. Accepted and expired states are displayed when returned, without an
accept action. Original text, type, payload, identity and offer_id remain available in JSON.
If a card cannot be read, retain the Message and expose offer_error; pretty output labels
the missing details. Unknown Message types are printed with their type and retained payload
rather than discarded. PubNub action envelopes remain excluded.
Not exposed: block/unblock, phone sharing, translation.
Watches, Checks, Events and Sinks are defined in CONTEXT.md.
-
watch add search [keywords...] [search flags] --name NAME [--notify SINK]... [--interval DUR] [--pages N] [--emit-initial]orwatch add search --from-alert ID --name NAME ...(see alert). -
watch add item ITEM --name NAME [--notify]... [--emit-initial] -
watch add seller USER --name NAME [--notify]... [--emit-initial]addruns the first Check immediately as a silent baseline; with--emit-initialthat baseline is reported (and delivered to Sinks) asitem.new/seller.new_itemEvents. -
watch list,watch remove NAME [--yes]. -
watch check [NAME...] [--all]runs one Check per named Watch (default: all due). Prints Events (JSON array, or one object per line with--format jsonl), delivers them to the Watch's Sinks, updates state. Idempotent; exit 0 even when nothing changed. Mints once when the Profile has a stored Session so the scheduled timer keeps it alive (see auth). -
watch run [--interval DUR]foreground loop:check --allevery interval until Ctrl-C. Interval default 5m, floor 30s, plus up to 10% jitter. -
watch events [NAME] [--since DUR] [--limit N]reads stored Event history. -
watch service install [--interval DUR]writes a systemd user service+timer (Linux) or a launchd agent (macOS) that runswallapop watch check --all --profile .... On Windows it prints theschtaskscommand to run by hand in cmd.exe (whole minutes, rounded up). Other platforms get a usage error. The unit pins the environment that decides where the CLI reads and writes:HOME,XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_STATE_HOME,WALLAPOP_CONFIG,WALLAPOP_PROXYand the three base-URL overrides go into the unit as they stood at install time, because a scheduler starts a job with a bare environment and the timer would otherwise check a different, empty state database and report nothing. A relative XDG value is left out, since the CLI ignores it too and falls back to theHOMEdefault; a relativeWALLAPOP_CONFIGis made absolute, since that one is read raw and the scheduler runs the job from its own working directory.WALLAPOP_SESSION_TOKENis never written: unit files are world-readable. For the same reason install refuses aWALLAPOP_PROXYcarrying credentials, and on Windows, where the printed task cannot carry it, anyWALLAPOP_PROXY(use the configproxy). Install refuses outright when a path that would go into the unit is relative, which happens whenHOMEitself is relative: systemd ignores a relativeStandardOutput=and would not look where the units landed.uninstallfails if the scheduler cannot be stopped. On Linux, install also runsloginctl enable-linger, without which a user timer stops at logout and does not start at boot.statussaysnot installedwhen the unit files are missing, otherwise what the scheduler reports:active/inactive/failedon systemd (unknownifsystemctl --useris unavailable), suffixed with a warning when the timer is not enabled or linger is off;loaded/not loadedon launchd. systemd also gives the last trigger time aslast_run.doctorshows the same state.uninstallremoves the units and the log. Output is appended to$XDG_STATE_HOME/wallapop-cli/wallapop-watch- <profile>.log;watch checkrenames it to.log.1once it passes 1 MiB, so the log is bounded by two files of about that size.Verified live on systemd 249 (2026-09-16), against the fake Wallapop, at a 30s interval: install registers, enables and starts the timer; it fires on install and on every interval; Events from the scheduled runs reach the log, the Event history and the Watch's Sinks;
statusreportsactivewith the last trigger time anddoctorreports the same; a log padded past 1 MiB is rotated to.log.1by the next scheduled run, which then starts a fresh log;uninstallleaves no unit file, no timer, no unit systemd still knows about and no log, andstatusanddoctorthen saynot installed. Reboot itself was not exercised, and stands onsystemctl --user is-enabledreportingenabledplus linger being on, which is what makes a user timer start without a login. The box already had linger enabled, so install'sloginctl enable-lingerbranch ran but had nothing to do. The launchd agent is unverified: no macOS host was available. Its plist is covered by a test that parses it and checks the keys launchd needs, and theschtasksline by a test that pins its quoting and rounding, but neither has run on its own platform.
Event types: item.new, item.price_changed, item.reserved, item.unreserved,
item.sold, item.removed, item.edited, seller.new_item. Event shape:
{"type":"item.price_changed","watch":"iphone","profile":"default","at":"2026-09-14T10:40:00Z",
"item":{"hash":"...","title":"...","price":{"amount":250,"currency":"EUR"},"url":"..."},
"change":{"from":300,"to":250}}Search Watches diff the first --pages pages (default 1): new hashes and price changes on
already-seen hashes. Item Watches diff the item detail. Seller Watches diff the user's item list.
Sinks live in config, not in the database:
[sinks.phone]
type = "ntfy"
url = "https://ntfy.sh"
topic = "wallapop"
token_file = "~/.config/wallapop-cli/ntfy-token" # optional
[sinks.discord]
type = "webhook"
url = "https://discord.com/api/webhooks/..."
# body: the Event JSON; set template = "discord" for a {content: ...} wrapper
[sinks.script]
type = "exec"
command = ["~/bin/on-wallapop-event"]
# receives one Event JSON on stdin per Eventsink list,sink test NAME(sends a synthetic Event).
wallapop mcp serves an agent harness over the Model Context Protocol, stdio
transport: newline-delimited JSON-RPC 2.0 on stdin and stdout, methods
initialize, ping, tools/list, tools/call. Protocol revisions answered:
2024-11-05, 2025-03-26, 2025-06-18 (a client asking for one gets it back, any
other gets the newest). Written against net/http-level primitives like the
rest of the client, with no MCP SDK.
- Each tool is defined as a CLI argv and runs the command in-process, so a
tool's output is the same JSON the command prints, by construction. A command
that fails comes back as a tool error carrying whatever it printed first and
then the
--error-format jsonenvelope, exit code included, so awatch checkthat broke halfway still reports the Events it committed; a malformed call (unknown tool, argument of the wrong type, missing required argument) is a JSON-RPC-32602instead, since no command ran. - Tools:
search,item_show,user_show,watch_check. Arguments mirror the flags with dashes as underscores (max_price,next_pagefor--next);conditionis an array,filteran object of key/value. Unknown argument names are refused rather than dropped. - Not exposed, and refused by name with the reason:
item reserve,item sold,item delete,item create,item edit. Destructive and money-adjacent actions stay with the person at the terminal. chat_list,chat_show,chat_sendandchat_startare written but gated off (chatToolsEnabledininternal/mcp) until live PubNub receive is verified end to end; until then asking for one says so.- Every invocation re-reads config and credentials, so a long-running server
follows a rotated session and picks up new watches.
--profilegiven towallapop mcpapplies to every tool call. wallapop mcp install claude-code|codex|cursorwrites the entry into~/.claude.json(or$CLAUDE_CONFIG_DIR/.claude.json),~/.codex/config.toml(or$CODEX_HOME/config.toml) and~/.cursor/mcp.json. The command is the running executable, the args aremcpplus--profile NAMEwhen one is given. Other entries survive, and so do keys inside this one that the CLI does not own (env, timeouts): onlycommandandargsare written. Codex's TOML is edited as text so comments and key order survive; an existing entry written in a spelling that rewrite cannot match (["mcp_servers"."wallapop"], an inline table) stops the install with the lines to paste, rather than appending a duplicate table that would break the file. Quoted and escapedcommandandargskeys are recognized by their decoded names. Table-valuedcommandorargs, including dotted keys and explicit subtables, stop the install without changing the file and must be edited by hand. A config that is a symlink is followed, so a dotfile manager keeps its file. Output is{harness,path,action,command,args}with actionadded,updatedorunchanged, so a second run is a no-op that says so. Configure an exec sink entirely through the CLI with a TOML array of strings:
wallapop config set sinks.script.type exec
wallapop config set sinks.script.command '["/home/me/bin/on-wallapop-event", "--verbose"]'
wallapop config get sinks.script.command
wallapop sink test scriptQuote the array for the shell. Invalid arrays or non-string elements return exit 2 and leave the previous config unchanged.
config path | list | get KEY | set KEY VALUEonconfig.toml.set proxyvalidates the scheme (http,https,socks5,socks5h), host and port, and refuses credentials because the file is world-readable; a rejected value is exit 2 and leaves the file untouched.doctorchecks: config parses, proxy in use (without credentials), Session mints,api.wallapop.comreachable, PubNub token obtainable, service unit state, and arenewalline marked as a warning ("warning": true, stillok) when the imported session cookie expires within 48 h and the renewal service is not installed. Exit 1 if any check fails.skills list | get NAMEprints the embedded agent-facing docs (usage, output shapes).completion bash|zsh|fish|powershell(cobra).
| flag | default | meaning |
|---|---|---|
--profile NAME |
config default | which Profile acts |
--format json|pretty|jsonl|toon |
json |
stdout rendering. pretty is for humans; jsonl streams one object per line (watch, chat open) |
--no-color |
off | also honours NO_COLOR, TERM=dumb, non-TTY |
--error-format text|json |
text |
stderr error rendering |
--no-input |
off | never prompt; missing input is an error |
--debug |
off | HTTP request/response log on stderr, secrets redacted |
-h, --help, --version |
-q/--quiet is not offered; JSON output is already minimal and --format covers the rest.
- stdout: data only, one JSON document per command (or JSON lines).
prettyoutput never goes to stdout when stdout is not a TTY unless asked for explicitly. - stderr: errors, prompts, progress, debug.
- Prompts only when stdin, stdout and stderr are all TTYs and
--no-inputis absent. - Secrets never appear in argv: cookies come from a file, stdin or the wizard.
| code | meaning |
|---|---|
| 0 | success (including "nothing changed") |
| 1 | generic failure |
| 2 | usage: bad flags, unknown filter key, missing location |
| 3 | auth: no Session, Session rejected, MFA pending |
| 4 | not found: item/user/conversation/watch |
| 5 | blocked or rate limited (CloudFront 403, 429), or session renewal paused after one |
| 6 | network / timeout |
| 7 | API changed: known endpoint returned an unexpected shape or status |
| 130 | interrupted |
Text errors are one sentence, lowercase-led, consequence plus remedy. JSON envelope on stderr
with --error-format json: {code, category, retryable, message, http_status, endpoint, suggested_commands, issue_url}.
Exit 7 always prints an issue_url pointing at
https://github.com/Microck/wallapop-cli/issues/new?template=api-change.yml&... with the
form fields prefilled: CLI version, OS/arch, command path (no argument values), endpoint path,
HTTP status, first 300 characters of the response body with tokens redacted. Plus a hint to
rerun with --debug.
User agent is wallapop-cli/<version> (+https://github.com/Microck/wallapop-cli) (verified
accepted on both hosts). If CloudFront answers 403 to it, the request is retried once with a
browser user agent and a one-time stderr note says so.
Paths via XDG (adrg/xdg):
| what | path | mode |
|---|---|---|
| config | $XDG_CONFIG_HOME/wallapop-cli/config.toml |
0644 |
| session references | $XDG_CONFIG_HOME/wallapop-cli/credentials.toml |
0600, locked/atomic updates |
| session vault | $XDG_CONFIG_HOME/wallapop-cli/galleton/ |
private directory, encrypted provider credentials |
| state | $XDG_DATA_HOME/wallapop-cli/state.db (SQLite, modernc.org/sqlite) |
0600 |
| logs | $XDG_STATE_HOME/wallapop-cli/ |
credentials.toml holds one table per Profile: Galleton session ID, device id, account hash, name.
Legacy cookies are read only for safe migration and are removed after successful handoff.
It is never relocatable by config, only by XDG variables.
Config (pelletier/go-toml/v2, unknown keys are errors):
default_profile = "default"
proxy = "socks5h://127.0.0.1:1080" # optional; WALLAPOP_PROXY overrides it
[profiles.default]
location = { lat = 40.4168, lng = -3.7038, radius_km = 20 }
[watch]
interval = "5m"
[sinks.phone]
type = "ntfy"
url = "https://ntfy.sh"
topic = "wallapop"Env: WALLAPOP_PROFILE, WALLAPOP_CONFIG (config file path), WALLAPOP_SESSION_TOKEN
(session cookie value for CI, wins over the credentials file), WALLAPOP_ERROR_FORMAT,
WALLAPOP_API_BASE_URL / WALLAPOP_WEB_BASE_URL / WALLAPOP_PUBNUB_BASE_URL (tests),
WALLAPOP_PROXY, NO_COLOR. WALLAPOP_PROXY (or config proxy) sends every Wallapop and
PubNub request and every session renewal through that proxy, ignores HTTPS_PROXY and
NO_PROXY, and fails with exit 6 rather than going direct when the proxy is unreachable;
an invalid proxy fails every command but config with exit 2. Without one,
HTTPS_PROXY/HTTP_PROXY apply to the CLI's own requests only. Precedence: flags > env >
config.
- Destructive on Wallapop's side, so prompt or
--yes:item sold,item delete. --yesaccepted but never required elsewhere.watch checkandwatch runare idempotent and crash-only: state is written after Events are delivered, so a crash re-emits rather than loses.- Ctrl-C exits within one in-flight request; a second Ctrl-C exits immediately.
- Never phones home. No analytics.
wallapop auth login # wizard, cookie export
wallapop auth login --cookies ~/Downloads/wallapop.txt --profile work
wallapop search "thinkpad x1" --max-price 400 --distance 50 --sort newest --format pretty
wallapop search --category 100 --filter brand=Toyota --filter max_km=120000 --limit 20
wallapop search filters --category 100 --format pretty
wallapop item show https://es.wallapop.com/item/thinkpad-x1-carbon-1092837465
wallapop item favorite k2j3h4g5f6d7
wallapop watch add search "thinkpad x1" --max-price 400 --name x1 --notify phone
wallapop watch add item k2j3h4g5f6d7 --name that-bike
wallapop watch check --all --format jsonl | jq -r 'select(.type=="item.new") | .item.url'
wallapop watch service install --interval 10m
wallapop chat list --unread --format pretty
wallapop chat send 8f1c2 "Sigue disponible?"
wallapop chat start k2j3h4g5f6d7 "Hola, lo recogería hoy"
echo "Te lo dejo en 200" | wallapop chat send 8f1c2 -
wallapop chat open 8f1c2
wallapop mcp install claude-code # then the harness runs `wallapop mcp`- PubNub subscribe (live receive in
chat open) was verified end to end on 2026-09-16 with a second account: receive, receipts and token refresh across a long session. The fake now serves the recorded envelopes rather than an invented shape. - Authenticated writes work with
Authorization+X-DeviceOS: 0alone; no extra device headers were needed. - Legacy XMPP transport exists behind a feature flag on the web; the CLI implements PubNub only.
Email+password login, creating or editing Items (image upload), Wallapop server-side saved searches (v2, read-only), accepting offers (deferred to a separately approved ticket), wallet, shipping execution or payments (never), settings edits (never), MCP server (v2), AUR/npm/Mintlify docs (after first stable release), Windows service install.
Sending and declining price offers are in scope despite their delivery API paths. Accepting enters the delivery checkout flow and remains absent; see ADR 0002.
cobra + pflag, net/http (no HTTP framework, no PubNub SDK, no MCP SDK), modernc.org/sqlite,
pelletier/go-toml/v2, adrg/xdg, goreleaser 2.x (homebrew cask, scoop, checksums with
GitHub attestations), curl | sh installer. Tests: stdlib testing, httptest.Server as a
fake Wallapop with redacted recorded fixtures, binary-level tests with an isolated XDG home.
No mocks.
cmd/wallapop/main.go entrypoint; lives under cmd/ so `go install` names the binary wallapop
internal/cli/ one file per noun (auth.go, search.go, item.go, chat.go, watch.go, ...)
internal/wallapop/ API client: session.go, search.go, items.go, users.go, chat.go, pubnub.go
internal/store/ sqlite schema, watches, events, seen-state
internal/watch/ check logic per target type, diffing, event construction
internal/sink/ ntfy, webhook, exec
internal/mcp/ mcp stdio server, tool table, harness install
internal/output/ json/jsonl/pretty/toon renderers, error envelope, exit codes
internal/config/ config.toml, credentials.toml, xdg paths
internal/cli/skills/ embedded agent docs (go:embed needs them beside the package)
docs/ this spec, ADRs