surfacer
Generate the interface instead of writing it.
Describe a surface once in a JSON file. Compile it into a CLI, an MCP server, an OpenAPI document, and a native binary that cannot disagree with each other, because nothing hand-wrote them.
Before and after, on one real form
A portal with no API
click #periodo
wait until enabled
(fields load from a
background call)
click #btnConsultar
parse the rendered table
retry when the markup movesThe same form, compiled
sunat-declaraciones \
f616 periodo --json
GET /v1/.../obtenerPeriodo/032026
Header: IdCacheUnderneath that form sits a JSON API. Reaching it needs a session token the portal mints only during its own browser login, which is why the DOM was the only door anyone found. surfacer captures the token once and reads the API headless for its hour.
What it does
Hand-written CLIs drift. --json lands on some commands, a banner breaks the parse, a subcommand never reaches --help. Every gap costs an agent a retry.
surfacer compiles one descriptor into every interface, so they cannot disagree. It does not map surfaces itself: that judgment belongs to the surface-recon skill or to a person writing the descriptor by hand. Bring your own IR.
One IR, six interfaces
The descriptor on the left is the source. Everything on the right is a build artifact. Edit the descriptor and every target recompiles as you type. Try renaming a command, or giving user a second parameter.
news-ycombinator-com - Hacker News
USAGE
news-ycombinator-com <command> [key=value ...]
COMMANDS
ask
formatdoc
front
jobs
newcomments
newest
news
show
threads
id=Hunter17
user
id=Hunter17
FLAGS
--json Print the raw response body
--help Show this helpEdit the descriptor and every target recompiles as you type.
It opens on Hacker News because that one you can check. Ten operations, eight of them plain reads, threads and user taking an id. Open the site in another tab and compare it against the descriptor.
Hacker News also publishes an official API, so nobody needs surfacer to read it. That is the reason it is the example here: you can audit the output against a published contract and confirm the descriptor invents nothing. The targets worth compiling are the ones with no such contract, which is what the auth section below is about.
The emitters running here are TypeScript ports of the ones in the CLI, reading the same descriptors from examples/. The CLI stays authoritative for the byte-exact artifact. That this page can compile an IR without importing the CLI is the point: the IR is a plain file, and every consumer is downstream of it.
Start
Install surfacer and run a real command against Hacker News in four lines.
curl -fsSL https://surfacer.dev/install.sh | shcurl -O https://raw.githubusercontent.com/crafter-station/surfacer/main/examples/news-ycombinator-com.surfacer.jsonsurfacer install ./news-ycombinator-com.surfacer.json --dest ~/.cargo/binnews-ycombinator-com news --json | jq '.items[].fields.title.value'No LLM tokens at runtime. macOS and Linux.
Or emit a target from an IR you already have. --out-dir goes before the target.
surfacer lint ./news-ycombinator-com.surfacer.jsonsurfacer emit --out-dir ./build ts-cli ./news-ycombinator-com.surfacer.jsonsurfacer emit --out-dir ./build mcp ./news-ycombinator-com.surfacer.jsonAuth the others skip
SDK generators assume a token you can mint yourself. Many portals don't work that way: they hand out a session token only inside their own browser login, with an audience your own client can never request. That single gap is why so many surfaces have no client at all.
surfacer models it. It captures the token once from the browser, then reads the API headless until it expires. The IR keeps acquisition and use separate, so the browser step runs once and the headless calls run for the token's whole life. That mode is why SUNAT reads clean today.
Auth attaches at the surface level and can be overridden per operation, because one host often mixes several. Peru's tax portal runs all three at once, and the sunat-declaraciones descriptor in the playground carries them together:
oAuth2as the surface default, for the SIRE sales register.browserBootstrappedTokenoverriding it on the F616 monthly declaration, the form whose fields stay disabled until a background call returns.none, stated outright, on the public padron lookup. An operation that needs no credentials says so instead of leaving a caller to find out.
The OpenAPI it emits
Those three states, compiled. This is the same SUNAT descriptor emitted as OpenAPI 3.1 and rendered in Scalar: OAuth2 on the SIRE operation, security: [] on the padron, and an x-surfacer-auth extension on F616, where OpenAPI has no vocabulary for the browser mode and the emitter declares it openly rather than faking an API key. Read only, since those endpoints answer to a real browser-captured token.
When the surface moves
This is the step the other three exist for. A surface with no official API has no deprecation notice either, and the usual failure is not a badly written client, it is that the target moved and nothing said so until an answer came back wrong.
surfacer check news-ycombinator-com --jsoncheck takes up to three endpoints from the IR as canaries, fetches their signatures, and compares them against a stored fingerprint. When one changes you update the descriptor and re-emit every target, which is cheaper than patching an integration you hand-wrote a year ago.
Two limits worth knowing. Drift covers HTTP only, and an IR whose operations are not HTTP has nothing to fingerprint. And a canary is evidence, not proof: three endpoints answering unchanged does not mean the response bodies kept their shape. Drift detected is a strong signal, drift not detected is a weak one.
Install
curl -fsSL https://surfacer.dev/install.sh | shmacOS and Linux. Releases, or build from source with cargo install --git https://github.com/crafter-station/surfacer surfacer.
Early development. The pipeline works end to end for public HTML sites, from an IR through every emitter.