A walkthrough of portage-cli from a clean machine — install, search-only
find, dry-run buy, and what to do when the free search backend comes back
empty.
With Homebrew (macOS or Linux), which installs the CLI and every adapter gem on Homebrew's own Ruby:
brew install tomtom87/portage/portageOr with RubyGems, on any Ruby ≥ 3.2, adding only the adapters you want:
gem install portage-cliThe gem pulls in portage-ucp, portage-ucp-client, and portage-ucp-journal
as dependencies. Upgrade later with brew upgrade portage or
gem update portage-cli; neither touches ~/.portage. If you have both,
whichever portage comes first on PATH wins; which -a portage shows which,
and portage doctor warns when a copy you didn't mean to run is shadowing the
other. On Linux, stored payment tokens need secret-tool from your distro
(e.g. libsecret-tools). See the CLI reference
for details.
Then set your shipping address (the country at least: without it some stores
report in-stock items as out of stock) in ~/.portage/.env, which portage
loads on startup, and check your setup.
~/.portage/.env:
PORTAGE_SHIP_STREET="1 Main St"
PORTAGE_SHIP_CITY="Erie"
PORTAGE_SHIP_COUNTRY="US"
PORTAGE_SHIP_POSTAL_CODE="16501"chmod 600 ~/.portage/.env
portage doctor!!! warning "Only ~/.portage/.env loads automatically"
A .env in the current directory is never loaded. A cloned repo's .env could
otherwise route your traffic through its proxy or point purchases at another
store without you noticing. Use a project file on purpose with
PORTAGE_ENV_FILE=.env. Why.
doctor reports how Portage was installed, the Ruby it runs on and which
adapters load, then lists anything to fix.
portage check https://your-shop.examplePrints a verdict (automated, webmcp, handoff or unsupported), the detected
platform and a next step. It sends plain GET requests only, never contacts a
hand-off-only host, and exits 0 only for automated and webmcp. Add --json
for the full report, and see Checking any store.
portage find --query "usb-c cable" --max-price 20 --jsonfind never touches payment or checkout — it only resolves candidate stores
and probes their /.well-known/ucp manifest. Safe to run freely.
portage buy --query "usb-c cable" --max-price 20 --dry-run --jsonSame search-and-probe pipeline as find, but shaped like a real buy call
so you can see the price/path resolution buy would use — --dry-run stops
before checkout, no charge either way.
--query is optional when the only positional argument doesn't look like a
URL (no scheme://, no . in it) — portage buy "usb-c cable" is
shorthand for portage buy --query "usb-c cable". A bare arg that does
look like a URL/domain (shop.com, https://shop.com) is still read as the
store to buy from, same as always.
portage buy https://some-ucp-store.example --query "hoodie" --dry-runPoint buy at a known store URL directly and it skips search entirely,
going straight to manifest probe. Against a domain with no UCP manifest (or
that doesn't resolve), it fails clean:
No automated path — visit https://some-ucp-store.example yourself. (source: none)
find/buy --query resolve candidate stores through search backends
(portage-cli/lib/portage/cli/search_backends.rb), tried in this order:
- Allowlist —
~/.portage/stores.ymlorPORTAGE_STORESenv var. No key, no network call, query-independent (always considered). - DuckDuckGo Instant Answer API — no key, always
available?, so it's the default when nothing else is configured. - Brave Search — needs
BRAVE_SEARCH_API_KEY. - Google Programmable Search — needs
GOOGLE_CSE_KEY+GOOGLE_CSE_CX.
With no keys and no allowlist file, DuckDuckGo is the only backend that runs. And DuckDuckGo's Instant Answer API is an entity resolver, not a web search — it answers "what official site does this named thing have," not "what stores sell this category of thing." Confirmed directly against the API:
curl -s "https://api.duckduckgo.com/?q=usb-c+cable&format=json&no_html=1&no_redirect=1"
# => "Results": [], "RelatedTopics": []
curl -s "https://api.duckduckgo.com/?q=ugreen&format=json&no_html=1&no_redirect=1"
# => full Wikipedia company entity (founder, industry, ticker...) but still "Results": []Generic category queries ("usb-c cable") and even well-known brand names
(ugreen, belkin, apple) routinely come back with an empty Results field —
DuckDuckGo just doesn't populate the official-site link reliably. A single
unambiguous brand query can work:
portage find --query "burton snowboards" --json{
"query": "burton snowboards",
"candidates": [{ "origin": "https://www.burton.com", "source": "duckduckgo" }],
"stores": [],
"offers": [],
"message": "Checked 1 store(s); none of them speak UCP."
}DuckDuckGo resolved burton.com, find probed it for a UCP manifest, found
none — a clean, correct empty result (burton.com just doesn't speak UCP),
not a search failure. This confirms the full pipeline works end to end:
search → candidate → manifest probe → graceful no-match.
DuckDuckGo-only is a keyless fallback, not a real search engine. To make
find/buy --query actually useful for category queries:
- Set
BRAVE_SEARCH_API_KEY— real web search, free tier available. - Set
GOOGLE_CSE_KEY+GOOGLE_CSE_CX— Google Programmable Search. - Seed
~/.portage/stores.yml(bare YAML array of URLs) orPORTAGE_STORES(comma-separated) — costs no network call, always considered regardless of query, good for stores you already trust.
portage doctor flags a DuckDuckGo-only setup itself (an info-level
search_backend finding, doesn't fail the run) so this is visible before
you hit a confusing empty result, and an empty find/buy --query result
now names the fix inline:
No candidate stores came back from duckduckgo for "usb-c cable". DuckDuckGo's
free API only resolves specific brand/product names, not open-ended search —
set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY/GOOGLE_CSE_CX for real web search
(see `portage doctor`).
All of these, and the PORTAGE_SHIP_* address, are listed in the repo's
.env.example.
https://ucptools.dev/directory lists ~100 stores with a self-assigned
"AI commerce readiness grade." Treat that grade as noise, not signal: the
site grades itself Grade A, and it's not verifiable — a grade doesn't mean
the store actually serves a UCP manifest. It has no API either, just an
HTML page.
Don't copy its list on faith. Instead, pull the raw domain list off the page and probe each one directly for a real manifest:
while read -r d; do
code=$(curl -s -o /tmp/resp.json -w "%{http_code}" --max-time 4 "https://$d/.well-known/ucp")
if [ "$code" = "200" ] && grep -qi '"ucp"' /tmp/resp.json; then
echo "$d"
fi
done < domains.txtOut of the ~99 domains listed, 38 answered with a real manifest (all on
Shopify, "version":"2026-08-25") — everything from Allbirds and Glossier to
Skims and The Body Shop. The other ~60 (Instacart, Trader Joe's, Tesco,
Whole Foods, etc.) returned nothing at /.well-known/ucp — not UCP stores
regardless of what grade the directory gave them.
Only the verified 38 went into ~/.portage/stores.yml. Once seeded, the
Allowlist backend picks them up automatically — no key, no network call to
resolve candidates, and (being query-independent) every find/buy --query
call considers all of them; each store's own catalog search decides whether
it stocks the thing you asked for:
portage find --query "usb-c cable" --max-price 20 --json
# => 38 candidates, source: "allowlist" — probed regardless of query,
# each store's catalog decides fitA fresh install only knows the stores in stores.yml or whatever a search
backend returns for one query. portage index gives find a standing,
local list to route queries to instead, built from sources you can read:
portage index sources # what each source fetches, and its file path
portage index build # every default source (Shopify's open catalog, your stores.yml)
portage index show --stores --json
portage index add https://thelightyard.co.uk --crawl # read one store's catalogue too
portage index search "bathroom pendant" # search it locally, no requestStored in ~/.portage/index/index.sqlite3, never in git, and
never carrying a price or stock field — those stay live. find also
merges in the repo's own published known-stores list automatically (over
jsdelivr, cached and refreshed periodically) even before you run index build yourself. The index is untrusted data on the same footing as any
other find candidate: it never feeds policy set --allow and never lets
--yes complete a purchase without you naming the store. Full reference:
CLI reference § Local store index.
portage browser import --dry-run --jsonReads your browser's bookmarks and history, reduces them to domains, and
keeps only the ones that answer /.well-known/ucp (or are already known).
Nothing is written until you review the kept[] list and re-run with
--yes:
portage browser import --yes --exclude some-domain-you-declined.exampleNever reads cookies, saved passwords, or browser autofill data — only bookmarks/history files, and only bookmarks/history. Details: CLI reference § Browser import.
On a real terminal, portage setup walks through the steps above
interactively — shipping address, search API keys, retailer offer source
keys, the agent profile, browser import, index build, spending caps and
hand-off target — one skippable step at a time, never echoing a secret
back:
portage setupPiped, from CI, or with --json, it's exactly portage doctor --json's
read-only report — always safe to run non-interactively.
find prints a search_id and a ref for each offer (--json has them as
search_id and offer_ref). Two commands turn those into the person's decisions.
Run them yourself, at a terminal, and they ask you on /dev/tty.
Pick the store. portage pick lists the latest search's offers, plus a last
choice, "Compare an offer across stores":
$ portage pick
1. https://shop.example — Cold Brew — 24.00 USD
2. https://other.example — Cold Brew — 22.00 USD
3. Compare an offer across stores
Pick an offer (1-3, v N to view, Enter to cancel): v 2
Opened https://other.example/products/cold.
Pick an offer (1-3, v N to view, Enter to cancel): 2
[picked] Picked Cold Brew from https://other.example — next: `portage buy --offer of_bbbbbb --dry-run`.
v 2 opens offer 2's product page in your browser and asks again. Viewing is never an
answer. Only pages on the offer's own store host are opened. Enter on its own cancels.
Choosing "Compare" asks which offer, runs portage compare on it, then shows the pick
again over its results.
Price it, then approve the total. A dry run saves a quote and prints its quote_id
(--json has it as quote_id):
$ portage buy --offer of_bbbbbb --qty 2 --dry-run
$ portage approve qt_5c0d1e2f3a4b
Cold Brew — https://other.example
qty 2, total 44.00 USD — https://other.example/products/cold
Buy 2 × Cold Brew from https://other.example for 44.00 USD? [y/N, v to view] y
[approved] Approved 2 × Cold Brew from https://other.example for 44.00 USD — next: `portage buy --quote qt_5c0d1e2f3a4b --yes`.
$ portage buy --quote qt_5c0d1e2f3a4b --yes
v opens the product page and asks again. buy --quote ... --yes buys exactly that quote.
If the price has gone up since the dry run it refuses (quote_changed) and nothing is
charged; each quote is used once.
The approval policy. --require-approval says what a real buy --yes needs:
portage policy set --require-approval person # only a yes you type yourself counts
portage policy set --require-approval any # the default: yours, or one an agent relays
portage policy set --require-approval off # `--yes` alone buys
portage policy show # ends with `require_approval: any (default)`Lowering it (for example person to off) asks for a yes at the terminal. It's stored in
~/.portage/policy.json. Under any or person, a buy --yes with no approved --quote
doesn't buy: it dry-runs and reports needs_approval.
!!! warning "Upgrade note"
Before this setting, --yes alone bought. Under the default any it no longer does. To
restore the old behaviour, run portage policy set --require-approval off from a
terminal.
person raises the bar but isn't a hard guarantee: an agent with a shell can edit
~/.portage/policy.json or the quote files, or run its own terminal. For agents, see
Agentic flow. The "Compare" choice uses your proxy
settings from the environment and config.json; pick has no --proxy flags.
Most stores don't let portage buy complete payment itself. It builds the
cart/checkout it can, then hands off:
portage buy https://some-shop.example --query "mug" --yes --handoff-target default-
Tier A,
default(the default): opens the checkout in your own browser — your login, saved address and saved card all apply. -
Tier B,
profile(opt-in): drives a dedicated Portage browser profile instead, up to the point of payment:portage browser profile open # once, to sign into your shopping sites portage buy https://some-shop.example --query "mug" --yes --handoff-target profile
Never your default browser profile, and limited to the store's own domain plus its checkout host — Portage never touches a payment field or clicks pay.
-
Tier C, hand-off only: Amazon (every marketplace) and any host you add to
~/.portage/config.json'shandoff_only_hostsare never sent a request at all —portage buyopens the page (or a cart-add/search URL) and reportsoutcome: "handoff_only"with alegal_notice, since these sites restrict automated purchasing agents in their own terms.
Portage is open-source software provided as-is, without warranty of any
kind (MIT) — how you use it on any given site, and compliance with that
site's terms, is your own responsibility. Full detail, including the
agent:<name> hand-off target for an approved external agent: CLI
reference § Tiers.
portage buy --query "hoodie" --dry-run --json \
--proxy http://user:pass@proxy.internal:3128 --no-proxy localhost,127.0.0.1--proxy/--no-proxy (and their PORTAGE_PROXY/PORTAGE_NO_PROXY env
equivalents, and ~/.portage/config.json's "proxy" section) are the
Portage-specific way to configure this — see proxy.md for corporate
egress, a rotating residential pool, an API gateway, mitmproxy for debugging, and
nginx/Cloudflare in front of the MCP/WebMCP endpoints, and
the CLI reference for the full flag/env
reference. Below that layer, the plain http_proxy/HTTPS_PROXY/NO_PROXY env
vars are still the fallback for any route left unconfigured — see
plain proxy environment variables
for how they're read. portage doctor reports the effective proxy per route, credentials
redacted.
Testing buy against a handful of the verified 38 (Casper, Glossier,
Olaplex — all real, live UCP manifests) all came back
"No automated path — visit ... yourself." even though curl https://casper.com/.well-known/ucp returns a full, valid manifest with
checkout/cart/catalog capabilities and a Google Pay handler.
Root cause: Portage::Ucp::Client.fetch_manifest
(portage-ucp-client/lib/portage/ucp/client.rb) expects the older flat
manifest shape — top-level services as an array of
{transport, endpoint} objects, top-level capabilities as an array of
{name} objects. The manifests these stores actually serve nest everything
one level deeper under an "ucp" key, with services/capabilities as
hashes keyed by service/capability name:
{
"ucp": {
"version": "2026-08-25",
"services": { "dev.ucp.shopping": [{ "transport": "mcp", "endpoint": "..." }] },
"capabilities": { "dev.ucp.shopping.checkout": [...], "dev.ucp.shopping.cart": [...] }
}
}Array(manifest["services"]) reads nil off the mismatched top level,
so mcp_endpoint never finds an endpoint and raises DiscoveryError —
silently swallowed by Buy#discover's rescue ... nil, which is why it
looks like a normal "not a UCP store" result instead of an error. Confirmed
directly:
ruby -Ilib -e '
require "portage/ucp/client"
Portage::Ucp::Client.discover("https://casper.com")
'
# => Portage::Ucp::Client::DiscoveryError: manifest has no mcp service entry to connect toThis isn't a per-store problem — every store in the verified allowlist will
hit the same wall until fetch_manifest/mcp_endpoint/capability_names
are updated to read the "ucp"-nested, hash-shaped manifest that real
stores (Shopify's rollout, as of 2026-08-25) actually serve.