Skip to main content

Run the API server

Not every service that needs an address parsed can load a 39.4 MB modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' — a Python worker, a Go service, a browser talking to your own backend. mailwoman serve puts the same pipelinestaged pipelineMailwoman's runtime architecture: a sequence of pure-function stages (normalize → query-shape → locale-gate → kind-classifier → phrase-grouper → classifier → decoder) connected by typed handoffs. Each stage is published as its own npm package. behind an HTTP endpoint on your own hardware. By the end of this page you'll have it running, you'll have curled a parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates., a geocode and a batchbatch sizeHow many examples the model processes before each parameter update. Larger batches give smoother gradients but cost more memory; gradient accumulation simulates a big batch on a small GPU. through it, and you'll know where its OpenAPI document lives. About ten minutes.

Prerequisites

Read the memory line before you start — it is the one that bites.

  • The first-parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. install: mailwoman, @mailwoman/neural, @mailwoman/neural-weights-en-us, on Node.js 24.18.0 or later.
  • candidate.db at <data root>/wof/candidate.db, if you want the geocode routes to return coordinates. Parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. works without it. Your first ten minutes has the command.
  • jq, for every curl below. Drop the pipe if you'd rather read the raw response.
  • Roughly 450 MB of memory per worker. --cpus defaults to every core the box reports, and each worker is a separate process that loads its own copy of the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' and opens its own database handles. Measured on this machine as proportional set size, a pair of workers read 354 MB and 407 MB under light traffic and 447 MB and 449 MB once both were warm, plus 220 MB for the manager. Budget the warm figure. Set --cpus to what you have room for; on a sixteen-core box the default is about 7.4 GB.

Two is a starting point with a measurement behind it. The threaded geocode path in this codebase was swept on a 16-core box against a single database and peaked at 2 workers, degrading from there — the ceiling is database and memory bandwidth, not core count. The reasoning is written up in geocode-stream.ts.

1. Start it

# Replace <YOUR_DATA_ROOT> with the data root `mailwoman doctor` reports for your install.
export MAILWOMAN_DATA_ROOT=<YOUR_DATA_ROOT>
npx mailwoman serve --port 7788 --cpus 2
[resolver] candidate-table backend (demo-parity, population-first): /tmp/mw-trial/mailwoman-data/wof/candidate.db

[resolver] candidate-table backend (demo-parity, population-first): /tmp/mw-trial/mailwoman-data/wof/candidate.db

Manager process: 3241417
Workers:
ℹ Listening (3241438) "HTTP server ready"
ℹ Listening (3241439) "HTTP server ready"

The two lines above the frame are the resolverresolverThe component that converts parsed address components (locality, region, postcode) into coordinates by looking them up in the gazetteer. The resolver ranks candidates by name match, population, and proximity, and returns the best-matching place with its centroid or polygon. naming the backend that answered, once per worker, on standard error — the path in them is the data root this page was verified against, so yours reads differently. Note that only the data root was set: serve looks for wof/candidate.db underneath it, so the backend it names is the one it found rather than one you selected.

Measured on this machine, /health answered 3.8 s after launch and the first parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. request 4.0 s after launch, with the modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' file already in the page cache from an earlier run; a first-ever start pays the read as well. Leave it running and open a second terminal for everything below.

--host defaults to 0.0.0.0, so the server is reachable from your network the moment it boots. There is no authentication in front of it. Bind it to 127.0.0.1 or put it behind something before it goes anywhere shared.

2. Ask it what it has

curl -s http://127.0.0.1:7788/health | jq
{
"status": "ok",
"uptime_s": 23,
"model": {
"name": "neural-weights-en-us",
"version": "7.0.0",
"locale": "en-us",
"labels": 33,
"format": {
"model": "ONNX int8 dynamic (quantized from fp32, opset 17)",
"tokenizer": "SentencePiece unigram, byte_fallback=true, vocab_size=73143 (v0.9.0-multisplice; ships WITH the model, both runtimes load it from this bundle)",
"max_sequence_length": 128,
"opset": 17,
"fp32_size_mb": 146.6,
"int8_size_mb": 37.6,
"pair_index_us_bin": "PIX1 binary, 1,323,912 bytes, 49,033 pairs, δ=10, β=5"
}
},
"data": {
"data_root": "/tmp/mw-trial/mailwoman-data",
"versions": null,
"wof_dbs": [],
"situs_states": 1,
"interpolation_states": 1
}
}

This is the route to point a load balancer at, and the route to read when a deployment answers differently than the last one did. model.version is the weights bundlemodel weightsThe learned parameters of the neural classifier, shipped as ONNX files in the @mailwoman/neural-weights-* packages. Weights are locale-specific bundles that include the model, tokenizer, and a model-card.json metadata file., not the package version, which is the field to compare across two hosts when their answers diverge.

The data block counts what it found under the data root. wof_dbs is empty because this deployment resolves against the candidate gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture. rather than a full WOFWOF (Who's On First). An open-source gazetteer of places maintained by Mapzen/whosonfirst. Mailwoman builds a custom SQLite database from WOF GeoJSON repos, extended with postcode data, importance scores, and coincident-role relations. distribution. situs_states: 1 and interpolation_states: 1 are the District of Columbia shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row. Geocode a CSV pulled — one jurisdiction out of the 52 the US bundleevidence bundleThe pair of retrieval-augmented input channels (street-type + locality-surface) that feed lexicon membership as soft per-token evidence alongside the text. Shipped in 6.7.0; trained natively from step 0 in the from-scratch base line. ships, which is why the geocodes below still answer admin. Those two counters are the fastest way to tell a deployment that lost a mount from one that never had the data.

3. Parse one address

GET /v1/parse is the one route that takes a query string, which makes it the one you can test from a browser address bar.

curl -s "http://127.0.0.1:7788/v1/parse?address=1%20madison%20ave%20madison%20wi%2053703" | jq '{input, components}'
{
"input": "1 madison ave madison wi 53703",
"components": [
{
"tag": "house_number",
"value": "1"
},
{
"tag": "street",
"value": "Madison"
},
{
"tag": "street_suffix",
"value": "Ave"
},
{
"tag": "locality",
"value": "Madison"
},
{
"tag": "region",
"value": "WI"
},
{
"tag": "postcode",
"value": "53703"
}
]
}

The response also carries a tree key that this jq filter drops — the same nested AddressTree, with spansspanA contiguous range of characters or tokens in the input string, tagged with an address component type (street, locality, postcode, etc.). Parsed addresses are represented as collections of spans, possibly nested in a tree. and confidences, that Read everything a parse returns walks. The flat components array is the order-preserving projection, which is what most callers want over HTTP.

4. Geocode one address

curl -s -X POST http://127.0.0.1:7788/v1/geocode \
-H 'content-type: application/json' \
-d '{"address":"233 S Wacker Dr, Chicago, IL 60606"}' \
| jq '{lat, lon, resolution_tier, uncertainty_m, locality, region, hierarchy}'
{
"lat": 41.837235,
"lon": -87.685969,
"resolution_tier": "admin",
"uncertainty_m": null,
"locality": "Chicago",
"region": "IL",
"hierarchy": [
{
"tag": "locality",
"value": "Chicago",
"name": "Chicago",
"lat": 41.837235,
"lon": -87.685969,
"placeID": "wof:85940195"
},
{
"tag": "region",
"value": "IL",
"name": "Illinois",
"lat": 40.124199,
"lon": -89.148632,
"placeID": "wof:85688697"
}
]
}

Same object the CLI and the library return, over the wire without lossloss functionA number measuring how wrong the model's predictions are on a batch of examples. Training minimizes it. Mailwoman's loss combines per-token negative log-likelihood with the CRF sequence loss..

POST /v1/batch takes an array and returns them in order, which is one connection and one round trip instead of N:

curl -s -X POST http://127.0.0.1:7788/v1/batch \
-H 'content-type: application/json' \
-d '{"addresses":["11 W 53rd St, New York, NY 10019","465 Huntington Ave, Boston, MA 02115"]}' \
| jq '[.results[] | {input, lat, lon, resolution_tier}]'
[
{
"input": "11 W 53rd St, New York, NY 10019",
"lat": 40.694457,
"lon": -73.93045,
"resolution_tier": "admin"
},
{
"input": "465 Huntington Ave, Boston, MA 02115",
"lat": 42.317974,
"lon": -71.088246,
"resolution_tier": "admin"
}
]

The batchbatch sizeHow many examples the model processes before each parameter update. Larger batches give smoother gradients but cost more memory; gradient accumulation simulates a big batch on a small GPU. route caps at 500 rows per request by default. Past that, chunk your input or run the loop in Geocode a CSV instead, which has no HTTP layerlayerOne transformer block — attention plus a feed-forward network, with normalization and residual connections — applied to every position. Stacking layers lets the model build up richer representations; Mailwoman's encoder has 6. to pay for.

5. Read the failures

A malformed request answers with a status code and a one-key object, not a stack trace:

curl -s -i -X POST http://127.0.0.1:7788/v1/geocode -H 'content-type: application/json' -d '{}'
HTTP/1.1 400 Bad Request
content-type: application/json
{"error":"address is required"}

400 for a request the server refused to run, 500 reserved for an engine fault. /metrics is the counterpart for the requests that succeeded:

curl -s http://127.0.0.1:7788/metrics | jq
{
"uptime_s": 60,
"timings": {
"total": 6,
"errors": 0,
"tiers": {
"admin": 5,
"batch": 1
},
"latency_ms": {
"p50": 11.32,
"p90": 37.66,
"p99": 37.66,
"max": 37.66
},
"latency_samples": 6
}
}

That count is lower than the number of requests this page has sent, and it is the thing to know before you wire the route to a dashboard: each worker keeps its own counters, and a scrape reads whichever worker answered it. Three consecutive scrapes of this two-worker server returned 4, 6 and 4 — one server, three answers. Run --cpus 1 when you want a single coherent set, or sum across scrapes and accept that the percentiles are per worker.

The tiers histogram is the operational read that matters over time: a deployment that starts answering admin where it used to answer address_point has lost a shardshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row., not accuracy.

6. Find the contract

The server emits its own OpenAPI document at /openapi.json.

curl -s http://127.0.0.1:7788/openapi.json | jq '{openapi, version: .info.version, paths: (.paths | keys)}'
{
"openapi": "3.1.0",
"version": "8.7.0",
"paths": [
"/health",
"/metrics",
"/v1/batch",
"/v1/format",
"/v1/geocode",
"/v1/parse",
"/v1/reload",
"/v1/resolve"
]
}

It is generated from the same route definitions the server runs (routes.ts), so it cannot drift from the deployment that served it. Point a client generator at that URL rather than at a copy.

Three routes this page did not curl: /v1/resolve takes an already-decoded address tree and resolves it against the gazetteergazetteerA geographical index that maps place names and postcodes to real-world coordinates. Mailwoman uses a custom-built Who's On First (WOF) SQLite database as its gazetteer — the 'atlas' half of the grammar/atlas architecture., for callers that parseaddress parsingThe process of decomposing a free-text postal address string into structured components — house number, street name, locality, region, postcode, and country — so a geocoder can resolve them to coordinates. in-process and want only that pass; /v1/format is the inverse of the parser, rendering components back to an address string plus a canonical match keycanonical keyA deterministic, normalized string representation of an address produced by @mailwoman/formatter. Lowercase, abbreviation-expanded, punctuation-stripped — so '123 Main St' and '123 MAIN STREET' produce the same key. Used for blocking in the matcher.; and /v1/reload reloads versioned data shardsshardA partial output file of the corpus build, written in Parquet format. The training pipeline streams shards row by row.. That last one is a deploy-time route on a server with no authentication of its own — gate it at your ingress, or it is a restart anyone on the network can trigger.

7. Stop it

Ctrl-C in the server's terminal. The manager closes the listener and shuts the workers down rather than dropping in-flight requests.

When to run a drop-in instead

mailwoman serve speaks Mailwoman's own /v1 shape. If the caller you're replacing already speaks someone else's, run the compatible server instead of rewriting the caller: @mailwoman/nominatim answers /search and /reverse in Nominatim's shape, @mailwoman/photon answers Photon's autocomplete endpoints with GeoJSON featurefeatureAn input signal a model conditions on. Beyond the raw tokens, Mailwoman feeds soft features — gazetteer-membership channels and the postcode anchor — that inform predictions without overriding them. collections, and @mailwoman/libpostal answers /parse and /expand. Each is a separate package with its own serve command, pointed at the same data root.

The rule of thumb: a new integration goes to /v1, which has the fuller result object; an existing integration goes to whichever drop-in matches the client you already have. Why those exist at all is on What Mailwoman is.

What you have now

An HTTP geocoder on your own hardware, with a health route to monitor, a metrics route to scrape, and a generated OpenAPI document to build clients from. It holds the same modelneural classifierThe machine learning model at the core of Mailwoman's parser — a transformer encoder (~30M parameters) trained from scratch to do BIO token classification over addresses. It learns the 'grammar' of address formats; the gazetteer supplies the 'atlas.' and the same data your CLI and library calls use, so its answers match theirs.

Next

  • Improve geocode precision — the download that moves situs_states: 0 off zero, and with it the tier this server reports.
  • What ships today — versions, footprints, and the per-localelocaleThe combination of language and country an address comes from. en-US and fr-FR are the locales Mailwoman ships weights for. picture.