okati
v0.1.0
Published
Act on the web from a CLI, so an agent never has to open a browser.
Maintainers
Readme
okati
Act on the web from a CLI, so an agent never has to open a browser.
Browsers corrupt context. The moment an agent opens DevTools it burns thousands of tokens on DOM it does not care about. okati takes that job: it drives the browser, and hands back a few hundred tokens of JSON.
okati amazon.com what can I do here
okati amazon.com search guitars do it
okati amazon.com lucky "guitar cable" search, take the first real result, cart itEvery command answers with one ok field and an exit code that agrees with it.
ok means the command did what it says on the tin, not that the process
survived: an add that cannot be confirmed against the cart exits non-zero,
because believing that one is how you buy two.
Install
Needs node 20 or newer.
curl -fsSL https://okati.sh/install | shThat is the whole setup. There is no account to open, no key to paste, and
nothing to configure: the first site command enrols this machine with the
okati service on its own, under a brand new account, and the only secret this
machine will ever hold is one okati token in ~/.okati/credentials.json,
mode 600. Nothing on this machine is signed in to any site until you sign in.
okati amazon.com what can I do here
okati amazon.com search "guitar capo" do it
okati wikipedia.org search hypertext any site, the same wayThe installer stages into a temp directory and proves the download loads before it writes the shim. An install that claims success and then dies on its first import reads as "okati is broken" rather than "okati is absent", which is worse.
Any site, learned on first use
okati has no list of supported sites. The first time anyone runs a subcommand on a site, it runs in a real browser; okati then proves the same request replays from a plain process and reads the same rows, and keeps that route only if it does. The next run, on any machine, is one HTTP request.
Measured 2026-09-18 on wikipedia.org from an empty home directory:
$ okati wikipedia.org
enrolling this machine with okati
discovered wikipedia.org in 4s
wikipedia.org Wikipedia
commands:
search <query> find things
$ okati wikipedia.org search hypertext
{"ok":true,"command":"search","kind":"page","title":"Hypertext", ..., "via":"browser","ms":14800}
$ okati wikipedia.org search hypertext
{"ok":true,"command":"search","kind":"page","title":"Hypertext", ..., "via":"replay","ms":475}The two answers are the same JSON. The only fields that differ are via and
ms, and they are always reported, because a slow first run that looks like
a hang is worse than one that says why it was slow.
What one machine learns is what every machine gets. The site's menu is fetched
from the service before a browser ever opens, so the second person on a site
skips discovery, and menus are ranked by how often each subcommand is run
across everyone. Menus are paged and searchable (--page=2, --find <word>),
never cut to a fixed number.
The rules, from docs/vision.md:
- The browser is the truth. A replay that returns a non-200, a robot wall,
or rows that do not match what the browser showed is a fallback reason on the
response (
fellBackTo), never a result. The browser answers instead. - Nothing personal enters the shared index. Requests are stored as names and placeholders, never values. One machine's own results, cookies and selectors never leave it.
- Verified means reproduced. A route is shared only after a replay from this process returned what the browser returned.
Counting
okati.sh shows three numbers, live: installs, commands run, sites indexed.
Every command reports its domain, subcommand, path (replay, browser or
local), whether it succeeded and how long it took, through a detached child
process that costs the command nothing. Never the arguments, never who. One
device counts a subcommand at most once every ten seconds, so a loop cannot
spin the public number. OKATI_NO_USAGE=1 turns reporting off.
Two logins, named apart
Everything below exists because these two were both called "login" until 2026-09-16, and a brand new install was therefore already signed in to an Amazon account nobody on that machine had ever typed a password for.
| command | what it is |
|---|---|
| okati auth login <invite> | join an EXISTING okati account from this machine (a fresh install makes its own account with no code) |
| okati auth status | which okati account and device this machine is |
| okati auth logout | forget okati on this machine; site logins untouched |
| okati amazon.com login <email> | sign THE ACCOUNT in to amazon.com |
| okati amazon.com login status | is the account still signed in to amazon.com |
| okati amazon.com logout | sign the account out of amazon.com, everywhere |
okati login, okati logout and okati whoami still mean the first three.
They are the original spellings and nothing that used them has to change.
The Amazon login is account-scoped, not device-scoped
This is the single most surprising thing about okati and it is deliberate:
- The Amazon session lives in a browser context the service owns, keyed to your okati account. It is never copied onto a laptop.
- Every device enrolled on one account shares it. Enrol a second machine and
it is already signed in, with nothing copied across. An earlier version of
this file told you to
scp ~/.okati/db/amazon.com.jsonbetween machines. That was wrong, and it is the reason the service exists. - A new okati account starts blank. Contexts are keyed by account, so a new account cannot inherit another one's login. There is a test that asserts two accounts get two different contexts.
okati amazon.com logouttherefore signs out every device, including ones that are switched off. It does three things, and reports each separately:- tells Amazon to end the session, in the browser, so Amazon's own token dies
- destroys the context holding the cookies, so the next browser opens blank
- moves the account's auth epoch, which is how the sign-out reaches the other machines
The epoch is the part worth understanding. Each install keeps a cookie jar so it can read amazon.com over plain HTTP in about a second with no browser (see The direct path). That jar is a copy, and a copy outlives the login it came from. So the service counts sign-ins and sign-outs per account, each jar is stamped with the count it was warmed under, and a jar stamped with any other value is deleted unread on that machine's next command. Nothing has to reach the other laptop; it finds out by asking. Signing in moves the epoch too, which is the case that matters if you sign in as a different Amazon account.
If okati cannot tell which epoch is current — the service is unreachable — the direct path stands down rather than using the last jar it saved. Not knowing which login is current is not permission to use the previous one.
Where the vendor went
okati's browser leg runs on Browserbase. That is deliberate and it has not changed. What changed on 2026-09-16 is who holds the credential.
Until then every install held BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID
and built its own CDP url with the key pasted into the query string. That is a
development setup with an install script on it. An end user has no reason to
open a vendor account, and shipping the key inside the package only means
anyone who installs it can read it back out.
So the key lives in the service and never moves. What the client gets instead is the connect url the vendor mints per session:
wss://connect.<region>.browserbase.com/?signingKey=<JWE>Measured against the live API rather than read off a docs page, that signing key
is a capability for one already-created browser and nothing else. Used as an
account credential it answers 401 on create-session, list-sessions and
create-context, and the session it names is COMPLETED the moment it is
released. It cannot open a second browser, enumerate the account, or spend
money.
That asymmetry is why there is no websocket relay here. Relaying CDP would put a 2.5MB Amazon page through a Worker for no security gained.
Two powers moved to the service along with the key. Reclaiming the plan's only session slot used to happen in the client, which meant every laptop needed permission to list and kill sessions across the whole account; nothing on a laptop can do that now. And browser contexts are created and mapped server-side, which is what makes the second machine work.
service/ is the whole thing: one Worker, one KV namespace, and three secrets
set with wrangler secret put. npm run deploy bundles the client into it and
ships it. The build refuses to produce a bundle containing anything that matches
a credential shape or any value from the building machine's .env.
How discovery works
Unknown domain means one page load in a real browser, about 20 seconds, and we keep only what the page volunteers about itself:
- its forms, which declare their own method, action and parameters
- the JSON endpoints it calls on its own
- its navigation, which is where cart, orders and login actually live
No crawling, no clicking, no classifying the site. Verbs are recognised by
shape, not by name: a type=search input or a lone text box is a search box on
every site in the world, whether the field is called k, _nkw or
search_query.
Nothing deeper is discovered until someone asks for it by name. Discovery is per verb, just in time, and the result is cached so the next agent skips it.
Commands carry their own next moves
Every response ends with the two or three things you can do next, built from what was actually on that page, so an agent never has to wonder whether there was a button it could not see. Results are numbered so follow-ups stay cheap.
Filters are flags on the command you already ran. They stay out of the default response and appear only when asked:
$ okati target.com search --help
filters on this page:
--facetedValue=vdy4o Women
--facetedValue=q7xor Men
--facetedValue=5zlb1 BoyFacet links carry the machine value in the query string and the human meaning in their own text, so we pair the two. The agent copies a flag without needing to understand the site.
What it looks like
$ okati ebay.com
ebay.com Electronics, Cars, Fashion, Collectibles & More | eBay
verbs:
orders your past orders
wishlist your saved items
account your account settings
deals current deals
search <query> find things
$ okati ebay.com search "fender stratocaster"
{"command":"search","count":15,"results":[
{"id":"377490623786","title":"Fender Stratocaster 1974 Red Solid Body...","price":"$2,000.00","url":"..."},
...]}Verified working, cold, on amazon.com, ebay.com and etsy.com. Three different stacks, zero per-site code.
Result rows are cleaned hard on the way out: redirect wrappers unwrapped, tracking parameters stripped, ad and chrome rows dropped, real product ids pulled from the path. A raw Amazon result URL is about 300 tokens on its own, which would defeat the entire point.
Finding a control
Two rungs, both free:
- A selector okati already learned for this site.
- What the web standardises.
autocomplete="username",autocomplete="current-password",autocomplete="one-time-code", input types, form-control shape. Sites maintain these so password managers work, which makes them the most portable signal available.
There used to be a third rung that asked a model, through the vendor SDK plus an
OPENAI_API_KEY, to find an element and return a selector. Both of those are
credentials an end user does not have and should not need, so the rung could
only ever fire for a developer. It was removed on 2026-09-16, and it took a
whole dependency out of every install with it.
Judgment on the browser path
The rung above is not coming back, and this is not it. That rung asked a model to FIND an element and hand back a selector, which meant the model drove the page. This one cannot: TypeSafe's Jev returns only a yes/no probability, a choice from a set the caller defines, or a position on a caller-defined rubric. It returns no strings at all. Code still gathers every candidate, code still owns the loop, and Jev only ranks rows code already has.
It fires only on the browser fallback, at three places where okati's querySelector heuristics are a genuine guess rather than a fact:
- Which control is the real add-to-cart. The ranker scores controls by how
much their name looks like the real thing, which is exactly the signal that
cannot separate
submit.add-to-cartfromnav-assist-add-to-cart. When the top two tie, the shortlist goes over as achoice. A clear winner is never asked about. - Whether the add actually happened.
cart addused to call itself confirmed when the ASIN appeared in the cart or when the cart merely had any rows at all — which is true of every cart that was not empty beforehand. The ASIN match is still proof and still costs nothing; only the guess is judged. - Whether a scraped row is coherent. Selectors slip silently and the row
still comes back, just wrong — on 2026-09-17 okati returned a brand name where
the product title belongs. Each row gets a
score, all in one request. A flagged row is named inlowConfidence, never dropped.
Three properties hold, and the tests grade all three:
- Optional. The key is read from a local vault at call time. An install with no vault entry resolves nothing, asks nothing, and behaves exactly as it does today. No end user needs a TypeSafe account, the same way none needs a Browserbase one.
- Fail-open. No key, a timeout, a 429, a malformed answer and a low-confidence answer all mean the heuristic wins. A judgment layer that can break a browser command is worse than none.
- Visible. Every judgment is reported in
judged, next tofellBackTo, even when it agreed with the code — otherwise a layer that quietly died would look identical to one that was working.
Measured cost and latency, and what the API does and does not tell you about rate
limits, are in docs/jev-envelope.md.
One live browser, no open or close
A CLI exits after every command, so the browser cannot live in the okati
process. It lives on the service's side instead. The first command
asks the service for a browser, which creates a keepAlive session or hands
back the account's existing one; every later command reconnects to that same
browser; the backend's timeout reaps it when nobody comes back. The agent never opens or closes anything.
That is what makes a code prompt survive between two commands:
okati amazon.com login <email> -> needs_code, the page stays open
okati amazon.com login code 481920 -> lands on that same page
okati amazon.com search "guitar capo" -> same browser, already signed inScope right now is deliberately one user, one agent, one site at a time.
A keepAlive session outliving its process is the point, and also the hazard:
on a plan that allows one concurrent session, a command that crashes leaves the
slot held for the full TTL and every later command fails with a 429. The service
handles that: when the backend refuses a new session it releases a stranded one
and retries. It will not release a session with a sign-in parked on it, because
a code prompt cannot be recreated and the code Amazon already sent would land
nowhere.
okati amazon.com session which browser, and whether its replay is ready
okati amazon.com session release give the slot back nowsession only reads. Asking "what browser am I on" must not open one.
Every session is recorded. The receipt is read from the backend's own replay
artifact, never from the flag okati set when it asked for recording -- a flag is
a request, a replay with pages in it is a receipt. It reports three states, not
two: available, pending while the browser is still open, and none. The
endpoint most examples use, GET /sessions/:id/recording, now answers 404 on
every session with "the rrweb-based Session Replay API is being deprecated", and
reading that 404 as an absence is a confident, wrong answer about a session that
is being recorded the whole time.
amazon.com is the one site okati is for
Everything above this reads a page the way any site would be read. amazon.com
gets its own reader, because the whole point of a shopping CLI is the fields a
shopper actually decides on, and Amazon publishes every one of them in
data-asin and data-component-type attributes that no site-agnostic heuristic
would ever look at.
okati amazon.com search <query> [--sort=..] [--prime] [--min=..] [--max=..] [--page=..] [--limit=..]
okati amazon.com product <ASIN>
okati amazon.com offers <ASIN>
okati amazon.com reviews <ASIN> [--stars=1..5|positive|critical] [--sort=recent]
okati amazon.com orders [--period=last30|months-3|year-2026|..]
okati amazon.com cart
okati amazon.com cart add <n|ASIN>
okati amazon.com cart remove <n|ASIN>--help on any of them lists its flags. --generic forces the site-agnostic
path back on, which is the honest way to compare the two.
$ okati amazon.com search "usb c hub" --limit=2
{"command":"search","query":"usb c hub","count":2,"sponsoredSkipped":4,
"url":"https://www.amazon.com/dp/{asin}",
"results":[
{"n":1,"asin":"B0BQLLB61B","title":"Anker USB C Hub, 5-in-1 USBC to HDMI Splitter with 4K Display",
"price":19.99,"was":24.99,"rating":4.4,"reviews":18800,"boughtPastMonth":10000,
"primeDelivery":"Tomorrow 4 AM - 8 AM","delivery":"Mon, Sep 21","badge":"Overall Pick"},
...]}Four things in that response are the whole argument for a per-site reader:
Sponsored rows are ads, not results. The generic extractor returned them
mixed in, including one whose id was the literal string clk, from
sponsored-ads.amazon.com/clk. They are now dropped and counted, so an agent
can see how much of the page was paid for without reading any of it.
An ASIN is an address. Every row carries one, and the page for it is
https://www.amazon.com/dp/<asin>. That is why no row carries a URL: a raw
Amazon result URL is about 300 tokens, the template is said once, and
cart add <ASIN> works on an item that was never in a search.
The delivery date is two dates. Amazon writes one cell that reads "Join
Prime to get FREE delivery Tomorrow 4 AM - 8 AM ... Or Non-members get FREE
delivery Mon, Sep 21". Those are different promises to different people, and
collapsing them into one is how a tool promises next-day delivery that is not on
offer. delivery is what this account gets; primeDelivery is what Prime would
get, and it is absent when Amazon did not offer one.
Numbers are numbers. 19.99, not "$19.99". 18800, not "(18.8K)".
offers is worth a line of its own: it reads All Offers Display, so it finds
the used and resale sellers the buybox hides. On the hub above the buybox says
$19.99 and offers finds the same item at $13.98.
The parsers are tested, and the browser is not involved
page.evaluate returns raw strings exactly as Amazon wrote them, and every
piece of interpretation happens back in node as a pure function. That is what
makes npm test possible at all: a parser that needs a browser to run is a
parser nobody can test. Every fixture in tests/parse.test.mjs was copied off a
live page through .probe/, never invented.
npm test.probe/ is the harness that found every selector in here. It attaches to the
session okati already owns rather than starting its own, because the plan allows
exactly one.
Amazon sign-in
amazon.com has explicit steps instead of the generic heuristics, and every screen Amazon can show maps to one status:
| status | meaning |
|---|---|
| ok | signed in, verified by visiting a page that needs a login |
| needs_code | Amazon wants a code from SMS, email or an authenticator. Run login code <code> |
| code_rejected | Amazon refused the code. Send the newest one |
| needs_approval | Amazon sent an approval prompt to a device. Approve, then login status |
| failed + no_account | no Amazon account for that email |
| failed + wrong_password | wrong password |
| captcha | a captcha Browserbase did not clear. Run login again |
| not_waiting | login code was run but the browser is not on a code prompt |
And signing out, where partial is the interesting one:
| status | meaning |
|---|---|
| ok | Amazon was told, the context is destroyed, the epoch moved |
| partial | okati destroyed what it holds, but could not tell Amazon. Amazon's own session token stays alive until it expires. Exits non-zero |
partial exists because a logout that quietly half-worked is the failure this
whole section is about. The fields say exactly which half: siteSignedOut,
contextDestroyed, contextOrphaned, browserReleased, cachedCookiesDropped.
Login and cart
There are two ways in, one for a person and one for an agent, and neither one puts a credential in argv:
okati amazon.com login [email protected] # prompts, echo off
<password source> | okati amazon.com login [email protected] --password-stdin
<authenticator> | okati amazon.com login code --code-stdinps shows every argument of every process to every user on the machine for as
long as the command runs, and shell history keeps it afterwards. So the prompt
writes to stderr with the echo off, and an agent pipes from a password manager.
A one-time code is a credential too, which is why it has the same door. Passing
a password as an argument still works so that an existing script does not break,
and it now prints a warning saying why not to.
It fills whatever field is in front of it, submits, and looks again, so one-step and multi-step forms (email, then password on the next page) work the same way. Credentials are never stored, on this machine or on the service. The signed-in session is kept in a browser context the service owns, so later commands are already signed in.
A one-time code is a command, never a browser. okati never opens a browser for you, and never asks an agent for a code it cannot get. When a site asks for a TOTP after the password, okati parks the live session and tells the agent exactly what to run next:
$ okati amazon.com login [email protected] --password-stdin < /dev/stdin
{"command":"login","status":"needs_code","method":"authenticator app",
"next":["okati amazon.com login code <code>"]}
$ okati amazon.com login code 481920
{"command":"login","status":"ok","next":["okati amazon.com cart"]}Sites that do not ask for a code simply return status: ok from the first
command.
How okati knows it is signed in: it does not read the login page. Reading a
login page means guessing from wording, which breaks on the first site that is
not in English. Instead okati navigates to a page that requires a login, one of
the orders, account or wishlist commands it already discovered, and sees
whether the site serves it or bounces to a sign-in. That is the whole test, and
it works on any site in any language.
$ okati amazon.com login status
{"command":"login status","signedIn":false,
"landedOn":"https://www.amazon.com/ap/signin?...",
"next":["okati amazon.com login <email>"]}Structure on the login page is demoted to a hint about why we are not in yet:
| status | what it means |
|---|---|
| ok | verified by visiting a page that needs a login |
| needs_code | a code field is waiting (autocomplete="one-time-code", or one-character boxes) |
| needs_approval | parked on an auth page with nothing to type: push approval, magic link or a security key. Approve it, then run login status |
| failed | the site rejected the credentials |
| unknown | okati could not finish and will not pretend to know why. Returns where it stopped |
unknown is deliberate. Inventing a confident answer is worse than admitting
the tool got lost.
The parked session stays alive between the two commands, so the code lands on the same page that asked for it. It expires after 15 minutes. Whoever runs okati supplies the code from wherever it lives, email, SMS or an authenticator; that is the harness's job, not okati's.
Captchas are Browserbase's job and sessions run with captcha solving on.
okati amazon.com search "guitar capo"
okati amazon.com cart add 1 # 1 is the numbered result
okati amazon.com cartcart add verifies by outcome, not by click. It clicks, waits for the redirect
to settle, then reads the cart and only reports ok if the item is really
there. Otherwise it says unconfirmed and tells you what it landed on. Anything
that reports success without checking will eventually lie, and it lied to us
three times while this was being built.
The direct path
Once you are signed in, okati stops using the browser for most work. The traffic was measured: every read is one HTML document GET, and Amazon authenticates it with cookies alone -- there is no JWT. So okati replays those requests straight from the CLI process and keeps Browserbase for creating the session and for anything the direct path cannot do.
Measured, one process per command, direct versus OKATI_DIRECT=0:
| command | direct | browser |
| --- | --- | --- |
| search | 1.9s | 8.1s |
| cart | 1.3s | 3.1s |
| cart add | 6.1s | 10.2s |
| cart remove | 2.8s | 5.3s |
| lucky | 7.0s | 17.4s |
It falls back on its own -- a non-200, a robot check, a dead session, a success
predicate that does not match, or a cart that does not confirm a mutation -- and
says so in fellBackTo so a slow run is never silent. OKATI_DIRECT=0 turns it
off entirely.
The request contracts live in schemas/amazon.com/v1/, one file per subcommand,
carrying the method, path, parameter classification, required headers, CSRF
dependency, success predicate and fallback triggers. Cookie names only, never
values. docs/amazon-protocol.md is why.
Status
Verified live against amazon.com, signed in to a real account:
- cold discovery, the command listing, and the generic path on any site
loginandlogin code, end to end through Amazon's two-step verification. This used to say login had never run against a real account. It has now.searchwith--sort,--prime,--min/--max,--limitproduct,offers,reviewscart,cart add,cart remove, as a full round trip that left the cart exactly as it found itlucky, end to end, against the real cart- the direct path for all nine operations, re-verified after a deliberate sign-out and a fresh sign-in, with the cart restored to its starting state
- the whole product path from a clean machine:
curl | shinto an empty home withBROWSERBASE_API_KEYandBROWSERBASE_PROJECT_IDabsent from the process,okati login, a real Amazon sign-in including the authenticator step, then search, cart add and cart remove, ending with the cart identical - a second and a third clean install on the same account, each already signed in with nothing copied to them
- session recording, read as a real replay on a session the service created
Not verified: orders against an account that has orders. The empty case is
verified, including reading the period list off Amazon's own filter rather than
hardcoding one that goes stale every January, but the account used for this work
has never placed an order, so the code that parses an order card has never seen
one.
Not built: checkout, repair when a stored command stops working. Checkout is deliberately absent.
Design notes
Failure is the discovery trigger. Nothing is crawled in advance and there are no background jobs. A verb only gets learned when someone asks for it, and only gets re-learned when it breaks. Maintaining a map of the internet is a losing game; repairing one route on demand is not.
Never classify a site. amazon.com has no obligation to resemble target.com. Everything here reads what a page says about itself.
okati is for acting, not reading. Exa, Parallel, Firecrawl and friends have the read side covered and do it better than we could. The bet is the act side.
