Traffic transparency — verify what Airmet sends
Airmet runs inside your network, next to your prompts and your provider keys.
You should not have to take our word for what it sends out. This page is the
complete inventory of the CLI's egress, plus a walkthrough for verifying the
telemetry pushes byte for byte with your own packet capture, and what you can
check about the other connections. airmet traffic prints the same
inventory locally (and makes no network calls itself).
In plain words. Telemetry is on by default, and the first airmet up
prints a one-time notice saying so. Opt out anytime with --no-telemetry: it
takes effect at once and is saved in the config file, so it holds for every
later run that reads that file. In Docker that file is under /data: without a
volume there, each new container starts as a first install, with telemetry on
unless that run is given --no-telemetry too. Since release 0.11.0 what is
sent is one record per request (telemetry v2): its timings and sizes, its
outcome (HTTP status and error class), the decision taken, each leg's route — provider, model and region, in clear — with
its token counts and the cost the provider reported, a session link (a random
session id, the turn, the request it continues), and labels as you configured
them: pool, scenario, each leg's id exactly as you named it (--providers
generates r1, r2…). Plus a random install id (am_…) created on your
machine on first run and kept across restarts. Never prompts, completions,
keys, model outputs, error bodies or URLs. AIRMET_TELEMETRY_SCHEMA=v1 sends
the older aggregate windows instead (below). Racing works the same with telemetry off. Opting out stops
the pushes; it does not delete the install id, which stays in the config file.
The served route/priors feed is a separate matter: it depends on airmet login
(a linked org token), not on the telemetry choice, and whenever a linked
install — opted out or not — registers or polls the feed, it sends its token
and its install id, and the route feed is also sent the public catalog routes
its legs use (the sections below say when each call happens).
What leaves your machine
Telemetry is on by default with a one-flag opt-out. The first airmet up
prints a one-time notice (so it is never silently on) and turns it on; opt out
anytime with --no-telemetry (or "telemetry": false in the config), and the
push stops entirely — the pusher is never even constructed. While it is on,
airmet up pushes per-request records (v2, the default), or with
AIRMET_TELEMETRY_SCHEMA=v1 the aggregate windows described further down:
| Endpoint | POST https://ingest.airmet.ai/api/ingest (override: AIRMET_INGEST_URL env or the ingest_url config key) |
| Transport | TCP 443 · TLS (SNI ingest.airmet.ai) · HTTP POST: HTTP/1.1, ALPN http/1.1, from release 0.11.1; release 0.11.0's pushes also offer h2, and the ingest picks HTTP/2. Plus a DNS lookup for that hostname whenever a new connection opens. An http:// override has no TLS, and an IP-literal one no lookup. |
| Auth | Authorization: Bearer <org token> when the machine is linked via airmet login (or AIRMET_INGEST_TOKEN env); on an unlinked machine there is no bearer and the daemon sends its pseudonymous install id instead (X-Airmet-Install, see Identity below). No other account or session handshake exists — the only handshake in a capture is the TLS handshake itself. |
| Payload (v2, the default) | airmet.audit.batch/v2, gzipped JSON: one record per request — the fields are in the next table — plus, per batch, a random batch id and its send time, the daemon's version, sampling rate, feed state and the id and sequence number of the priors bundle it serves, each pool's configuration (policy, α and its other dials; every other serving setting by its flag's name — timeouts, guards, rescues and their dials, values only, never a key, URL, path or a name you gave something; and each leg's id, route and settings: its engine, usage and cache-key style, rate limit, declared parameters, abort-billing override, whether its routing is pinned, and how many extra headers and body members it adds, never which), its cumulative request counters, and any cost-guard trip. Records are sampled per conversation at the rate the server sets, and none older than 90 days is sent. |
Payload (v1, AIRMET_TELEMETRY_SCHEMA=v1) |
airmet.audit.window/v1 JSON, ~4 KB per window: timing percentiles (TTFT/ITL) plus the window's mean service time (service_time_ms — its mean first-token time, a single averaged number, used to size the shared fleet), second-leg win/loss counts, per-leg win/429 counts keyed by each leg's id exactly as named in your config (--providers generates r1, r2…; an id that names the provider would leave the machine as that name, so name config legs opaquely, like leg0), the guarantee_level label, the leg ids, a set of grouped tallies (see the field table below) that break the window down by request class, prompt-size band and provider pair, the window's prompt-cache token totals (the provider's own cached-vs-total prompt-token counts, summed — never a prompt or its contents), and — only when you set AIRMET_REGION — a coarse region label you chose. When a served-priors feed is in use it also carries the version of the priors bundle the sidecar currently has applied (its id + a monotonic sequence number + a freshness label), so we can measure how fast the fleet adopts new routing data. Never the bundle's contents. The Go daemon also stamps a fixed surface label and the verdict_coverage counts (both in the table below). |
| Cadence | v2: one POST per interval the server sets, or sooner once enough has queued; at most 2,000 records, 4 MiB uncompressed and 256 KB compressed per POST. v1: one flush every 30 s, at most 32 windows per flush, one POST per window. Idle → nothing. |
| Identity | linked machines: the org bearer header above, plus the same install id on X-Airmet-Sidecar — a per-daemon rate-limit key, not a second identity (the ingest refuses a bearer next to X-Airmet-Install). Unlinked machines: the pseudonymous install id (am_…, or tc_… on an install created before the Airmet rename) as X-Airmet-Install — a random id minted on first run, stable across restarts, tied to nothing about you or your machine; the payload's other correlatable ids are a random per-process id (epoch_id), regenerated on every restart, and, in v2, a random id per conversation. Sending the install id is new with the Go daemon (2026-09-05): the earlier CLI kept it local, and this page and airmet traffic changed in the same release, as promised here. |
What a v2 record carries (one per request)
| Part | What it is |
|---|---|
| request | a random request id, its arrival time, the request class (plain, tool, reasoning), whether it streamed, and the labels: pool, scenario, surface, region (only when you set AIRMET_REGION), version, config hash |
session |
a random id per conversation, which source told its turns apart (a task id, a prompt-cache key or the conversation's opening — the name of the source only, never its value), the turn number, how the turn links to the one before, and the request id of the turn it continues |
shape |
message and assistant-turn counts, the byte sizes of the prompt, the tool definitions and the newest messages, and the prompt's token count where airmet tokenizes it (not on the OpenAI-compatible plane) — sizes and counts only, never content |
decision |
the policy, how many legs were asked for and launched, whether the α gate opened and the random draw that decided it (a hash of the random request id), the q_alpha quantile the request asked for, when the plan was final, stall and reissue flags, whether a loser may be cut and why not, why the request ran with fewer legs than it asked for or with none raced (a fixed code: too few healthy legs, a policy error, the cost guard, a kept loser still running, or relayed unraced — never which parameter sent it to the relay), and the cost guard's state |
client / outcome |
first-token and end-to-end times, when the request was cut off (the client left, its deadline, a shutdown), the HTTP status and error class the client got, which leg served, whether a backup rescued it and which rescue (from which leg to which). A request airmet answered itself before any leg ran — load shed, no pool, a request it could not accept — is a record too, with no legs and the error code it was answered with, so the X-Airmet-Request-Id on every response names a record |
arms |
per leg: its id as named in your config, its role, its route — provider, model, region (a private gateway, your own server or a model id that names your account reads as custom), what the upstream said it served, its outcome, timestamps from the request's arrival (its launch, its planned fire time, connect, sent, headers, first byte, first token, first answer, last token, finish, cancel, abort sent and abort acknowledged), its HTTP status and error class (never a message or body), its token counts (prompt, cached, completion, reasoning) and the cost the upstream reported, the tokens airmet counted itself where it tokenizes, the price the cost guard put on it (the listed per-token rates of the marketplace endpoint that served it, public list prices, and the cost they give), the abort-billing verdict in force, and the policy's plan for it (its fire offset, and the latency estimates the deferred policy compared) |
| also | reissue_of (the request id a reissued request repeats); whether the request was a probe or an exploration draw, ended early (and whether by the first-token deadline), finished degenerate, or failed; agreement: whether a finished loser's answer was compared with the winner's, and whether they matched — never the answers or a hash of them; and per leg its index, why a backup was declined, how a leg was cut, whether it claimed the race and then failed, where its verdict and latency prior came from and the bundle keys they were read under, the 429 reissue's verdict (attempts, the wait it honoured, whether one recovered, why no further one was sent) and the throttle gate's (its pool's pressure, never the pool), the kind of a 429, whether its connection was reused, how many requests were in flight on it when it was sent, its stream chunk count, its cache-write tokens, where its usage numbers came from, and whether the upstream returned a generation id (never the id) |
Never in a record: prompts, completions, model outputs, tool arguments, API
keys, error bodies, generation ids, URLs or hostnames, or a hash of any
content. A record is cut from the local record by an allowlist, and the rows
above name each part it keeps; airmet traffic prints the same list for the
binary you run.
With AIRMET_TELEMETRY_SCHEMA=v1: the aggregate keys in a window
Every window breaks its own traffic down along three axes. These are grouped counts and coarse buckets computed on your machine, not a per-request log.
| Key | What it is |
|---|---|
by_class |
Request count and a first-token percentile summary, split by request class: plain, tool (tool-bearing), and reasoning (the response streamed model thinking — a delta.reasoning marker; no thinking text, only the fact and the count). Derived from whether the request carried tool definitions or the model reasoned. No tool names, arguments, or contents — only which class and how many. |
by_context_band |
The same split by context band — a coarse prompt-size bucket (s/m/l/xl, bracketing ≈0.5K / 2K / 6K / 12K input tokens). The band label only, never your actual token count. |
pairs |
Per pair of legs (both leg ids, as named in your config) and band: how many times the second leg fired, which leg was slower, how often the backup won, and how many of those had an uncensored primary. Counts only — no timings per request, no vendor names. |
prompt_cache |
Four numbers for the whole window: how many committed responses the provider reported token usage for (n), the total prompt tokens across them, the cached subset of those tokens, and the resulting cache-hit rate. These are the provider's own token counts (from the usage block it returns — e.g. prompt_tokens_details.cached_tokens or cache_read_input_tokens), summed on your machine. Token totals only — never a prompt, its contents, or any per-request count. Absent for a window whose provider reported no token usage. |
surface |
A fixed label naming which distribution surface produced the window: daemon, docker, pip, npm, sdk-python, sdk-node or edge. Set by the image, the SDK wrapper or a flag; otherwise pip or npm when the binary is the one that package installed, else daemon. Only the label leaves, never a path or anything else about your machine. |
verdict_coverage |
Four counts of your configured legs by their abort-billing verdict: how many legs, how many run on a provider that refunds a cancelled leg, how many on one that bills it, how many unknown. Counts only — no provider names, no routes. It tells the factory which routes still need a measured verdict. |
airmet traffic prints these same keys in its payload description, and every
v1 value is visible verbatim in the local windows.jsonl mirror before anything
is sent.
The second flow: the served-priors fetch
The push above is not the only connection Airmet opens to our host. A linked install also pulls the served-priors bundle — the fleet-tuned routing defaults the sidecar warm-starts from instead of its built-in cold ones. It is inbound (a download), it recurs, and it is authenticated, so it belongs here in the same detail as the push:
| Endpoint | GET https://ingest.airmet.ai/api/feed/priors (override: AIRMET_PRIORS_URL env or the priors_url config key). Today this is the same host as the push endpoint — so a capture filtered on ingest.airmet.ai is not all POSTs. |
| When it happens at all | whenever a saved org token from airmet login (or AIRMET_INGEST_TOKEN) is present. The gate is identity, not consent: a linked install pulls priors whether telemetry is on or off, an opted-out linked install pulls and pushes nothing, and an anonymous (unlinked) install has no bearer to send and makes no priors call at all. airmet traffic prints which case this machine is in. |
| Method | conditional GET. Once a validator is held it sends If-None-Match, so a poll while the bundle is unchanged comes back 304 Not Modified with no bundle at all. |
| Auth | Authorization: Bearer <org token> — the same credential the pushes carry. |
| What it sends | no request body, and nothing about your traffic. The only headers are that bearer, the same pseudonymous install id the pushes carry (X-Airmet-Install, the random am_… id described under Identity above — it lets the feed apply your plan's fleet cap per daemon) and, once one is held, If-None-Match. No prompts, completions, metrics or request counts ride this call, and no identifier other than that install id — it is a download, not a report. |
| What it receives | a signed priors bundle: Ed25519, verified against the public key(s) pinned in the build, rejected if unsigned, past its freshness bound (the bundle's own ttl_s or 48 h, whichever is shorter), or rolled back to an earlier sequence number. A missing, unreachable or unverifiable feed never blocks serving — the racer keeps the priors it has, or runs cold. |
| Cadence | a first GET within 10 s of startup — or, when the cached bundle warm-booted the daemon, anywhere in the first ~3600 s — then every ~3600 s with ±15% jitter (so a restarting fleet does not synchronise on the endpoint) and full-jitter exponential backoff while unreachable. A restart's first GET is conditional (the cache keeps the ETag), so it is usually a 304. |
| Where the response lands | a verified bundle is written byte-for-byte to ~/.local/state/airmet/priors-cache.json ($XDG_STATE_HOME is honoured), so a restart during a feed outage still boots warm. That file is the response — you can verify its signature yourself and diff it against your capture. |
Two honest caveats about verifying this one. It is not recorded in
egress.jsonl — that transcript covers pushes only; the fetch shows up in the
sidecar's own log lines (priors applied, with the bundle id and sequence) and
in the cache file above. And --tls-keylog is scoped to the pusher's
connection, so it does not decrypt the priors fetch: in a capture you get
that connection, its SNI and its sizes, not its plaintext. airmet traffic --verify says both of these in the walkthrough rather than leaving you to
discover an opaque flow and wonder.
The same flow, per route: the route feed (Go daemon)
The Go daemon asks for priors per route instead of downloading the whole bundle, when the install is registered and its pool has a public catalog route: an endpoint a marketplace's public catalog lists (see One catalog read below), or a leg's own route as the telemetry names it — a named provider's public model in its default region, which a leg on a known provider's API host has even with no route configured (a fine-tune counts as its base model). It is the second flow in another shape, so it replaces the bundle GET above rather than adding to it:
| Endpoints | POST https://ingest.airmet.ai/api/feed/routes and GET https://ingest.airmet.ai/api/feed/routes/heartbeat — the same host as the priors feed, so the same capture filter catches both. |
| When it happens at all | a linked install with an install id that registers at boot (the feed answers registered installs only; --register=false keeps the bundle GET) and at least one public route. Telemetry plays no part, as for the bundle. --route-feed=false keeps the bundle GET instead. A control plane that publishes no route feed answers 404, and the daemon polls the bundle until it restarts. |
| What it sends | the same bearer and X-Airmet-Install, and a JSON body listing only the public catalog route keys of your pool and the version it holds of each. A route that is not a public catalog route — a custom endpoint, a hostname, a fine-tuned model id — is never sent, not even hashed. For a marketplace endpoint the body also carries the public price the catalog read below listed for it (its rates, its discount and when it was read), so a price change — a promotion starting or ending — reaches us as soon as any daemon sees it: catalog data, nothing of yours, and we record it without the install id. The heartbeat GET has no body. |
| What it receives | one Ed25519-signed entry per route — a latency prior, an abort-billing verdict and a quarantine flag — each with its own TTL, never past its verdict's expiry; and a signed heartbeat naming the routes the fleet has quarantined. Both are verified against the same pinned key(s), and an entry older than the one held is refused. Only a signed entry releases a route from quarantine. Unreachable or unverifiable: serving goes on with the entries held, then the baked bundle. Each reply also states your org's feed access: which models the feed covers for your org, and until when it covers every route. The daemon shows it on /admin/stats (route_feed.access) and in airmet status, and never acts on it: a route the feed does not cover for your org comes back withheld, and the daemon runs it as it runs any route the feed does not know. |
| What we keep | per org, the public models the route keys name (never the routes, never which install asked, nothing from your traffic), and in how many hours of each day a query named each, for 14 days after the last query that named one: the models we pick for your org's feed are picked from them, and until your org chooses its own model, the one its queries named most stands in for it. The catalog prices are kept without the install id, as above. |
| Cadence | a first POST within 10 s of startup, then again as each entry's TTL runs out (15 minutes to an hour today, less as a verdict nears its expiry; at most 5 minutes for a quarantined route) or at once when the catalog read finds a route's price changed, with routes due within a minute or a tenth of their TTL sharing one POST. The heartbeat GET is conditional, every ~300 s from startup. |
| Where the response lands | the verified entries, as served, and any catalog price the feed has not recorded yet, in ~/.local/state/airmet/route-feed-cache-<port>.json, one file per daemon by its /v1 port ($XDG_STATE_HOME is honoured); the state is on /admin/stats (route_feed). |
The third flow: fleet registration (Go daemon, linked installs only)
The Go daemon (airmet up, airmet-hedgerd --use-config) makes ONE more call, added
2026-09-05 with per-daemon fleet registration:
| Endpoint | POST https://ingest.airmet.ai/api/feed/register — the same host as the priors feed (override: AIRMET_PRIORS_URL; the registration route is derived from that URL's origin) |
| When | at boot, in the background, and only when the install is linked (a saved org token). Anonymous installs never call it. --register=false disables it. If the control plane answers 429 or 5xx, or cannot be reached, the POST is retried: at most 8 attempts, with jittered exponential backoff, never sooner than the server's Retry-After. A denial (403), a refused token (401) or any other 4xx is final and is not retried. |
| What it sends | the org bearer, the same pseudonymous install id the feed poll carries (X-Airmet-Install, also as sidecar_id in a one-line JSON body). Nothing else — no config, no provider URLs, no traffic. |
| What it gets | admit or deny under your plan's fleet cap. A denied daemon keeps racing on the release-baked bundle: the cap gates the live feed and the dashboard panel, never the code. A denial also says what running that daemon takes, and where to ask. The outcome is the last fleet registration: boot-log line — one line per attempt while it retries. |
| What we keep | the install id on your org's roster when it is admitted; and, admitted or denied, that it asked, for 30 days after its last POST. That is how your org's instances are counted, which decides one extension of what the route feed covers. |
| Local trace | the boot log (fleet registration: …, one line per attempt), /admin/stats (registration) and airmet status. It is not in egress.jsonl (that transcript covers pushes only). |
Linked pushes also carry the daemon's install id on a second header, X-Airmet-Sidecar,
so ingest can rate-limit per daemon instead of per org token; it is the same id, on a
different header, because a push may carry only one identity (the bearer) and the
pseudonymous identity header is refused alongside it.
What never leaves
- Prompts, completions, and model outputs — raw request records stay on disk in your results directory.
- API keys, error bodies and URLs. (Provider and model names do leave in
v2: each leg's route names them, and each leg's
idleaves as you wrote it.) - Hostnames, IPs, or any machine/user identifiers — none appear in the payload.
- Any telemetry when you opt out (
--no-telemetry): the pusher object is never constructed, so no record or window ever leaves. Opting out works identically to before the default flipped — the off switch is unchanged. It holds for every run that reads the saved choice: a Docker container with no volume on/datacannot, so pass the flag on eachdocker runor mount one. (A linked install still polls the priors feed — next bullet.) - Any served-priors fetch when the machine is not linked: the feed needs the org
bearer, so an anonymous install makes no outbound priors call — it runs on
its built-in cold defaults plus the verdicts baked into the binary. The fetch
is gated by identity, not consent: a linked install that has opted out of
telemetry still polls the feed and pushes nothing. The bundle GET carries
only the bearer and the install id; the route feed's POST also lists the
public catalog routes your legs use. To stop that too, unlink the machine
(
airmet login --clear).
One thing we will not pretend away: making the connection at all reveals your egress IP address to our server, exactly as it would for any HTTPS request to any host. That is a property of TCP/IP, not of our payload, and you can see it in your own capture. What we claim is narrower and checkable: the contents we receive are the records (or, under v1, the windows) above and nothing else.
Everything else the binary talks to is yours: the replica/provider URLs you
pass to up/conform, and the sidecar's own /v1 port on loopback for
status and health.
No third party. The Go binary fetches nothing from a model hub: it has no
tokenizer download and no --model launch plane (both belong to the frozen
Python reference, which does not ship in the package).
What goes to your provider
Traffic to your own upstreams never passes through us, but you should know its
shape too. With airmet up --engine openai (an OpenAI-compatible provider),
each raced leg sends your client's chat body verbatim: the messages array
exactly as your client wrote it — every role, every turn, unflattened — plus
only the parameters your client actually set from a fixed list: the sampling
ones (temperature, top_p, max_tokens, max_completion_tokens, stop,
seed, user), the tool surface (tools, tool_choice,
parallel_tool_calls) and the cache hints (prompt_cache_key,
prompt_cache_retention); the output-shaping ones (response_format,
logprobs, top_logprobs, logit_bias, prediction) only when every leg in
the race is known to honour them. Parameters your client omitted are not
filled in with defaults, and no seed is invented for a request that did not ask
for one. No other field is forwarded on a raced leg: the sidecar reads its own
knobs (hedge, task_id, scenario, …) and drops them, and a request it
cannot race the same way on every leg — one with a field it does not know, n
above 1, a non-zero penalty, non-text content or a legacy functions tool,
among others — is not raced: it is relayed to the first leg as your client
sent it (with that leg's model and configured fields), and X-Airmet-Bypass on
the response says why.
What Airmet itself sets on a raced leg: model, replaced with the upstream
model id you configured for that leg (this is what lets one request race two
providers); stream, because the sidecar always consumes upstreams as a
stream — your own streaming/non-streaming choice is honoured in the response
you get back; stream_options: {"include_usage": true}, so the leg reports
its token usage (unless that leg's stream_usage is off or its upstream
refused the option; your client's own stream_options is not forwarded); and,
under twinleg, a per-leg session id, as an x-session-id header (or as a
prompt_cache_key on a leg configured for that, when your client sent none),
so each leg keeps its own warm cache. Each leg also carries what you configured
for it: its key as the bearer, the provider object of a leg pinned to a
marketplace provider, and any extra_body members and extra headers, verbatim.
One catalog read, for marketplace legs only. For each model a leg reaches
through a model marketplace whose catalog lists every provider serving it, the
daemon also reads that model's public endpoint list from the leg's own API root: GET <base_url>/models/<author>/<slug>/endpoints, once
at startup and about hourly after that (sooner while the read is failing, with
backoff). It carries no API key, no body and nothing from your traffic; a twin
leg on the same model shares the one read. The answer — which providers serve
the model right now, and at what price — stays on your machine, where it prices
your legs. --route-discovery=false turns it off.
The cost guard's reads, for marketplace legs only. The Go daemon compares
what each leg was charged with what its live price predicted, and stops racing a
route whose charges drift more than 10% from the prediction. For that it may read
the endpoint list above once more, at once, when legs are charged more than 10%
off the listed price, either way (a promotion that ended or started is a price
change, not a fault). And for a
leg it cut, whose bill arrives late, or a reply it re-asked for because the first
came back empty (the first was billed too), it reads that generation's billing row:
GET <base_url>/generation?id=<the leg's generation id> on the leg's own API
root, with the leg's own key — the host and key its chat request already used — a
few times until the row arrives, for at most an hour. The id, the key and the row
stay on your machine. --cost-guard-ledger=false turns the read off. What leaves
is the comparison, and only in v2 telemetry, the default (the v1 windows carry
none of it): each record's guard state
(decision.cost_guard: closed, open, half_open or unguarded) and, when a
route trips, a guard_events entry — the route, charged ÷ predicted, how many
priced legs, the window in seconds and when it opened. No generation id, no key,
nothing from your traffic. A trip is sent only for a public catalog route: one on
a fine-tune or your own deployment stays on your host.
A raced request means your prompt is sent to more than one of your configured
upstreams — that is the mechanism, and the per-request hedge object reports
exactly how many calls were launched. Tool definitions are forwarded verbatim:
a turn whose tools are client-executed is raced and only the winner's tool calls
are returned; a turn carrying a provider-executed (or unknown) tool is relayed
to one leg without a race, and the response's hedge.server_tool says so —
never a 400.
Sign-in is a separate, explicit command. airmet login links a machine to
your org with a device flow: it makes HTTPS POSTs to two endpoints on the
public site — one to https://airmet.ai/api/device/code, then one to
https://airmet.ai/api/device/poll every few seconds until you approve the
code or it expires (override: AIRMET_SITE_URL env or the site_url config
key) — and only while you are running it. The first POST carries your
machine's hostname (os.Hostname()) alongside the client version, so the
browser page where you approve the code can show you which machine is asking;
the site keeps it on the pending device grant, and once you approve, the CLI
token it creates is named after it (CLI login — <host> — <date>). It is sent
only by airmet login — never by a telemetry push, which carries no hostname
at all. It stores identity, not
consent: a linked machine with telemetry off pushes nothing (it still polls the
priors feed, which is gated by identity — see the second flow).
Captured packets to ingest.airmet.ai therefore contain exactly three things:
the consented ingest POSTs, and — on a linked install — the served-priors GETs
(or the route feed's POSTs and heartbeat GETs in their place) and the
fleet-registration POST at boot described above (repeated only while
the control plane is rate-limiting, answering 5xx, or unreachable). All carry the
bearer header when linked. No update check,
no activation check, no traffic to the sign-in endpoints unless you invoked
airmet login. If you see anything else addressed to ingest.airmet.ai in a
capture, that is a bug — we want to know.
airmet traffic prints this same inventory locally, and
TestTrafficListsEveryEndpoint (go/internal/cli/cli_test.go) enumerates the
endpoint constants the binary resolves and fails the build if that command
omits one. That test exists because
this exact page stayed correct while the CLI command did not: the served-priors
feed shipped, this page was updated, and airmet traffic was not — for a
release, a reviewer who ran the command and then tcpdump would have found a
recurring authenticated GET the command never mentioned.
Behind a proxy
When the environment names a proxy, airmet up sends all of the CLI's egress
described above through it: the requests to your providers, the telemetry
pushes, and the other flows to ingest.airmet.ai; airmet login does too. It
reads the variables most HTTP clients read:
HTTPS_PROXY(orhttps_proxy) forhttps://URLs, andHTTP_PROXY(orhttp_proxy) forhttp://ones;NO_PROXY(orno_proxy) to exempt hosts, domains and address ranges;localhostand loopback addresses (127.0.0.1,::1) always go direct, so a model server on the same machine is never proxied. One elsewhere on your network is, unlessNO_PROXYnames it.
Up to release 0.11.0 the requests to your providers ignored these variables and went direct, while the telemetry pushes used them.
What the proxy sees. For an https:// URL the daemon opens a CONNECT
tunnel naming the host and port (each provider's API host, and ours), and the
TLS session runs from Airmet to that host inside the tunnel. So the proxy sees
which hosts Airmet connects to, when, and how many bytes cross each connection.
A proxy that inspects TLS, with its own certificate authority trusted by the
machine, can read the requests as well. An http:// request reaches the proxy
whole.
Credentials in the proxy's URL (http://user:password@proxy:3128) go to the
proxy, as its Proxy-Authorization header. At boot the daemon logs the proxy
each leg goes through, as scheme://host:port only
(leg leg0 (<host>) egress: via proxy http://proxy:3128 …), or that the leg goes
direct and why; airmet traffic prints which proxy your shell names, the same
way. A packet capture of the machine then shows connections to the proxy, not to
the hosts on this page, and the proxy resolves those hostnames, so the machine
looks up only the proxy's: capture on the proxy's address instead.
The local mirrors
airmet up writes two audit files next to its raw records, so you can
inspect what was shared without any capture:
airmet-results/up-<ULID>/windows.jsonl— every closed 10 s aggregate window, pre-wire shape, on every run, telemetry on or off. Under v2, the default, no window is sent: the file is a local summary, and the v1 format's mirror.airmet-results/up-<ULID>/egress.jsonl— while telemetry is on, every HTTP push: URL, headers, the body and its SHA-256 (for a v2 batch, the JSON as it was before its gzip, plus the SHA-256 and size of the compressed bytes that crossed the network), and the response status (failed pushes are transcribed too). Past 5 MiB the file is renamedegress.jsonl.1, replacing the one before, and a new one starts, so it holds the recent pushes rather than every one. One deliberate redaction: the bearer token is never written. The transcript records the header asBearer sha256:<first 16 hex of sha256(token)>and marks the recordauthorization_redacted: true— so if you capture a packet, hashing the token it carries lets you match it to the transcript, while the transcript itself can never leak the credential. Pushes only — the priors fetch is not transcribed here.
The priors fetch has its own on-disk artefact, written whenever a bundle verifies and independent of the results directory:
~/.local/state/airmet/priors-cache.json— the last verified bundle, byte-for-byte as the feed served it.~/.local/state/airmet/route-feed-cache-<port>.json— the route feed's verified entries, each as served, the quarantines it holds, and any catalog price the feed has not recorded yet, kept to be sent again; one file per daemon, by its/v1port.
airmet traffic --last 5 pretty-prints the most recent transcript entries.
Verify it in Wireshark
Telemetry travels over TLS, so a plain capture shows ciphertext. The CLI can write the TLS session secrets for its own telemetry push connection only in NSS key log format — the file Wireshark's "(Pre)-Master-Secret log filename" setting consumes. The keylog never covers your inference traffic, so a decrypted capture cannot expose prompts or provider keys. It does not cover the served-priors fetch either — step 6 below says what you can check about that connection instead.
Start airmet with telemetry and a keylog:
airmet up --config hedger.yaml --telemetry --tls-keylog ~/airmet-tls-keys.logCapture (hostname filters resolve at capture start; dropping the host term and filtering later also works). Both flows share a host today, so one filter catches the pushes and the priors fetch:
sudo tcpdump -i any -w airmet.pcap 'tcp port 443 and host ingest.airmet.ai'If you have overridden only one of
AIRMET_INGEST_URL/AIRMET_PRIORS_URL, they are on different hosts and this filter would miss one — runairmet traffic --verify, which builds the filter from your resolved endpoints.Send some requests through airmet, wait at least one 30 s flush, stop both.
Decrypt: in Wireshark set Preferences → Protocols → TLS → "(Pre)-Master-Secret log filename" to the keylog, or with tshark:
tshark -r airmet.pcap -o tls.keylog_file:$HOME/airmet-tls-keys.log \ -Y http.request -T fields -e http.host -e http.request.uri -e http.file_dataThat line reads HTTP/1.1, which the pushes speak from release 0.11.1. Release 0.11.0's pushes run over HTTP/2: read them in Wireshark's HTTP/2 view (display filter
http2) instead.Compare: every decrypted POST body, once its gzip is undone (Wireshark and tshark undo it by default), should SHA-256-match a
body_sha256line inegress.jsonl, and the compressed bytes itswire_sha256. Expect gzipped batches of per-request records, up to 256 KB each (withAIRMET_TELEMETRY_SCHEMA=v1, uncompressed windows of about 4 KB), and the push connection's SNI and ALPN as listed above. The registration, feed and route-feed connections negotiate their own ALPN, usuallyh2. The TLS handshake at the start of each connection is the only handshake there is.Account for the priors fetch. On a linked install you will also see a recurring
GET https://ingest.airmet.ai/api/feed/priors— same host, and not decryptable with the keylog, which is scoped to the pusher's client. What you can check without decrypting it: it carries no request body; the response, whenever a bundle verifies, is written byte-for-byte to~/.local/state/airmet/priors-cache.json, so that file is the response and you can verify its Ed25519 signature yourself and diff it against what you captured; and the sidecar logs every applied cycle (priors applied, with the bundle id and sequence). On an unlinked machine this connection should be absent from your capture entirely (an opted-out but linked machine still shows it — identity, not consent) — if it is there on an unlinked machine, that is a bug and we want to know.If the machine is linked (
airmet login --status), each decrypted POST also carriesauthorization: Bearer <token>. Hash that captured token and compare against the transcript'sBearer sha256:<16 hex>value — the header is verifiable even thoughegress.jsonlnever contains the token:printf '%s' '<token from the capture>' | shasum -a 256 | cut -c1-16Delete the keylog file.
Keylog safety
- The keylog is opt-in only (
--tls-keylogorAIRMET_TLS_KEYLOG); it is never written by default, and Airmet deliberately ignores the genericSSLKEYLOGFILEvariable so it never mixes its keys into a keylog you exported for browser debugging. - Anyone holding the keylog plus your capture can read the telemetry push payloads (nothing more — it covers that one connection, not the priors fetch and not your inference traffic). Treat it like a temporary secret and delete it after verifying.
- Neither client turns redirects off: like any Go HTTP client they follow up to 10, and drop the bearer header on a redirect to a host outside the original domain. A redirect in your capture is worth reporting to us.
The website (this site), not the CLI
The same rule the CLI lives by applies here, and you can check it with the network tab instead of Wireshark:
No analytics or error reporting loads until you allow it. The consent banner's default is decline; ignoring it equals declining. Before you click "Allow analytics", the only cookies are the essential sign-in session (if you log in to the dashboard) and — after you make a choice — one first-party cookie,
tc-consent, that stores the choice itself. Open devtools on any page: pre-consent, not one request leaves this origin.If you allow it, the site starts usage analytics — and error reporting, once that is configured — only from endpoints we operate or that are EU-resident, and every hostname is listed here:
What Host your browser talks to Where the data rests Usage analytics (PostHog Cloud EU) eu.i.posthog.com—POSTs onlyFrankfurt, Germany (AWS eu-central-1)Error reporting none configured — The analytics code is part of this site's own bundle, fetched from this origin after you click Allow; no script is loaded from PostHog, and it is built so that it cannot load one. What it sends is the pages you view and when you leave them — the page's address cut down to site and path (never its query string or
#fragment, and only the section of a signed-in dashboard page), the site you came from (not its page), your browser, OS and screen size, and a random id it keeps in your browser's local storage (not a cookie). Your IP address is discarded when the event arrives, recordings, heatmaps, surveys and click tracking are off, and we never attach a name, e-mail or account to it. Choosing Decline later stops it on the open page and deletes the stored id.Whether analytics is switched on right now is not something to take from this page:
GET /api/consent/configreturns the configured origin, and an emptyanalyticsOriginmeans off — then nothing loads even after Allow. "Cookie settings" in the footer reopens the banner, so the choice is never one-shot.Nothing you type on this site reaches a model. The signed-in dashboard's demo replays recorded data, and the dashboard's endpoints that took prompts or a provider key answer 404 on the pilot site. Production runs where it always does: the sidecar runs in your infra, and the one URL change points at your own instance.