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 moves

The same form, compiled

sunat-declaraciones \
  f616 periodo --json

GET /v1/.../obtenerPeriodo/032026
Header: IdCache

Underneath 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.

10 ops
news-ycombinator-com.surfacer.jsonsource
emit help
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 help

Edit 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.

shell
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.

shell
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.json

Auth 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:

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.

shell
surfacer check news-ycombinator-com --json

check 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

shell
curl -fsSL https://surfacer.dev/install.sh | sh

macOS 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.