How to connect it, what a receipt proves, how somebody checks one without us, and what it cannot see.
In one paragraph. Lyntway watches AI traffic, records what was in it as classes and counts, and signs that record. The receipt verifies against a published key with no account and no network, so your evidence does not depend on us continuing to exist. It never stores your content.
The official SDKs read the base URL and the key from the environment, so setting these two governs every call the application makes — including code you did not write and libraries you do not control. No code changes, no new files. Restart and it is live.
OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 OPENAI_API_KEY=$LYNTWAY_KEY~$YOUR_OPENAI_KEY
The two keys are joined by a tilde because an SDK gives you one key field and no way to add a header. We split them here, forward yours, and keep neither.
Do not paste a snippet into a new file. That governs only the calls inside it while the rest of the application carries on reaching the provider directly — which looks exactly like the product not working.
Change it where the client is built, and everywhere it is built.
client = OpenAI(
base_url="https://YOUR-LYNTWAY/gw/openai",
api_key=LYNTWAY_KEY, # we authenticate on Authorization
default_headers={"X-Provider-Key": OPENAI_API_KEY},
)
Anthropic is simpler — its own x-api-key passes through
untouched, so only the base URL and an Authorization header
are needed.
Cursor and most editor integrations offer a base URL and a single key box, with no way to add headers. Join the two keys with a tilde and paste that in — we split them, forward yours, and keep neither.
A limit worth knowing before you rely on this. In these editors a base URL applies only to models you add with your own key. Whatever comes bundled with their subscription is routed to their own servers, and nothing you configure here changes that. This covers a team bringing its own provider keys; it does not cover a team on the editor's plan.
Base URL https://YOUR-LYNTWAY/gw/openai/v1 API key $LYNTWAY_KEY~$OPENAI_API_KEY
Most MCP servers run as a subprocess of the agent over a pipe, where no proxy can see them. Point the agent at the shim and it spawns the server itself.
{"mcpServers": {"github": {
"command": "lyntway-mcp",
"args": ["--", "npx", "-y", "@modelcontextprotocol/server-github"]
}}}
The shim needs no account. Run on its own it signs with a key it makes at startup and throws away, which is useful for seeing what your own tools handle and worth nothing as evidence to anybody else — the key belongs to the person being audited. Sign in and it signs with a key that does not.
Tool results are governed on the way back, which is the direction that matters: a result carrying a customer's details is about to become part of a prompt. Detection happens on that machine — the content is never sent anywhere to be inspected.
Classes and counts are reported to your account once the machine has been
signed in with lyntway login, so laptop activity appears in
Traffic beside everything else. Nothing else leaves: not the values, not
the tool's arguments, not a digest of the result. Those receipts are
recorded as attested — the shim watched it and told us,
which is weaker than us having watched it, and the receipt says so.
Portkey, Kong, LiteLLM, Cloudflare — keep them. Add yours as an endpoint in the console and point your application at us instead. They carry on routing; we see the traffic on the way past.
Turn on watching-only first if they already inspect this traffic. Otherwise we substitute values before their guardrails ever see them, and you keep paying for a check that finds nothing.
pip install lyntway litellm_settings: callbacks: [lyntway.litellm.handler] environment_variables: LYNTWAY_URL: https://YOUR-LYNTWAY LYNTWAY_KEY: $LYNTWAY_KEY
Neither can be put behind a proxy: both sign every request in a way that breaks the moment something sits in the path. Route them through a gateway that signs for you, add the Lyntway plugin to it, and every call is recorded on the way past.
Lyntway does the detection and the signing, as it does everywhere else —
the plugin runs our rules and our models over the content and returns a
receipt signed with your key. What it does not do is watch the call
leave, because your gateway made it, so the action is recorded as
asserted rather than observed. Attribution
comes from your gateway's own key, so a finding lands on the person who
caused it.
It records and never blocks: the callback runs after the response is back, so there is nothing left to change. If we are slow or unreachable the call still succeeds and the receipt is simply missing, which the coverage report shows. A recorder that can fail your traffic is one you would remove.
OTEL_EXPORTER_OTLP_ENDPOINT=https://YOUR-LYNTWAY OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer $LYNTWAY_KEY
Every service that speaks it appears, with no integration written for any
of them. Evidence is attested rather than
observed — we vouch for having received the report, not for
having watched the traffic.
Everything above changes configuration, not source. So putting Lyntway into a real deployment is putting two environment variables where that deployment already keeps them, and restarting.
# In the image ENV OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 # Or at run time, which keeps the secret out of the image docker run -e OPENAI_BASE_URL=... -e OPENAI_API_KEY=... your-app
services:
api:
environment:
OPENAI_BASE_URL: https://YOUR-LYNTWAY/gw/openai/v1
OPENAI_API_KEY: ${LYNTWAY_KEY}
A Secret and an envFrom. One manifest change, rolled out with
your next deploy.
apiVersion: v1
kind: Secret
metadata: { name: lyntway }
stringData:
OPENAI_BASE_URL: https://YOUR-LYNTWAY/gw/openai/v1
OPENAI_API_KEY: lynt_sk_...
---
# in the Deployment's container spec
envFrom:
- secretRef: { name: lyntway }
The environment-variables panel, then restart. No code deploy is needed, because no code changed.
Set the same two as repository or organisation secrets. Test runs that call a model are then governed too, which is usually where the first surprise about what your test fixtures contain turns up.
LangChain, LlamaIndex, CrewAI, AutoGen and the Vercel AI SDK call the official provider SDKs underneath, so the environment variables reach them with nothing framework-specific to do.
The exception is code that constructs a client itself with a hardcoded base URL. Change it where the client is built — everywhere it is built. A single new file governs only the calls inside it.
The tilde exists for editors that give you a single key field. A server deployment has headers and a secret manager, so it should not need it: store your provider key once in Settings, and the application carries only ours.
OPENAI_BASE_URL=https://YOUR-LYNTWAY/gw/openai/v1 OPENAI_API_KEY=$LYNTWAY_KEY # no tilde, no provider key
This is worth more than the tidier configuration. An application that holds no provider credential cannot reach the provider directly, so "our AI traffic is governed" stops depending on everyone remembering to route politely. Try it: point the base URL back at the provider and the call is refused, because nothing in the application can authenticate to them any more.
The trade is stated rather than buried. Until you store one, this service holds no credential of yours at all, which is the stronger answer in a security review. Self-hosted, the key never leaves your own estate and there is no trade to make.
If your client sets its own headers, X-Provider-Key carries
the provider's key separately and needs neither the tilde nor stored keys.
| Mode | What happens | Choose it when |
|---|---|---|
| Watching only | Detects, records, signs. Content passes through unchanged. | Something else already protects this traffic |
| Protecting default |
Also substitutes values before content leaves. | Nothing else is in the path |
A request your policy refuses is refused in both. The receipt says which mode was in effect, so it never reads as though protection was available and simply did not trigger.
This is the part that matters, and it works without us. The tools are public and free, under Apache-2.0, and checking a receipt needs no account — an auditor is not a customer and should not have to become one:
brew install lynt-x-global/lyntway/lyntway
Or take a build for your platform from the releases page. The source is at lyntway-tools, and it has no third-party dependencies at all, so what goes into the tool that checks receipts can be read in an afternoon.
$ lyntway-verify -keys keys.json receipt.json
VERIFIED
decision redact
findings pci.card_number ×1 → redact, pii.email ×1 → redact
signed by lyntway-t-6d48e64e… (ed25519)
Fetch the key once from /.well-known/lyntway-keys.json and
the check runs offline forever after. No account, no network, no
cooperation from us — which is the point: evidence that requires the
issuer's permission to verify fails exactly when the issuer is the party
in dispute.
Any RFC 8785 and RFC 9052 implementation works. There is also a browser page at /verify for convenience, and a public endpoint — but both require trusting us, which makes them the weakest of the three.
Everything needed is in the receipt and the published key file. Two signatures are involved, and the order matters.
issuer.key_attestation is our root
key vouching for it. Canonicalise that block with its
signature field removed, prepend the domain separator
lyntway-key-attestation-v1\x00, and check it against the
root key named in root_key_id, which you fetch from
/.well-known/lyntway-keys.json.
signature field removed, and check
it against key_attestation.public_key. No domain separator
here: the receipt is signed over its canonical bytes directly.
The domain separator on the first is deliberate. Without it, a signature made over one kind of statement could be replayed as though it were the other — a standard precaution, and the one detail somebody reimplementing this will otherwise spend an afternoon on.
Canonicalisation is RFC 8785: keys sorted by UTF-16 code unit, no whitespace. Signatures are Ed25519 (RFC 8032), base64 in the JSON. The whole check is about thirty lines against any standard crypto library, and it depends on nothing we publish except the public key.
Three fields carry the weight, and absent always reads as the weakest possibility.
observed | We were on the wire and saw the bytes ourselves |
attested | Another system reported it. As good as your trust in that system |
asserted | The caller described its own action |
full, degraded or bypassed. A
receipt issued while the model tier was down says degraded
rather than claiming a scan that half happened.
An empty detector means the deterministic ruleset — reproducible, because the ruleset digest on the receipt lets anyone re-run the same patterns months later. A named detector means a model formed an opinion: better recall, no guarantee that re-running agrees. Both are real findings. They are not the same kind of evidence.
Stated here rather than left to be discovered, and published per account
at /v1/coverage:
Detection is imperfect in both directions. Model findings produce false positives on ordinary business writing — "ignore the second column, it is a duplicate" reads as an injection attempt to a classifier. That is why injection findings are recorded and never enforced on by default.
| Kept | Not kept |
|---|---|
| Classes and counts of findings Receipt digests Your account and key fingerprints |
Your prompts and responses The values we detect Your provider credentials The receipts themselves, unless you configure a destination |
Decide where your receipts go before you rely on them. Without a destination they are returned to your application and retained by nobody — we hold their digests, so a receipt you kept can be checked, but one nobody kept cannot be produced by us. Two ways to keep them are below.
Every governed action produces a signed receipt. By default it is returned to your application and then kept by nobody — we hold its digest and nothing else. That is deliberate, and it means a new account can generate evidence all week and have none of it when somebody asks.
Two ways to keep them, and a deployment should choose one on day one.
Settings → Receipt forwarding. Every receipt is posted to an address you control, as it is signed. Nothing to change in your application, and it covers traffic from tools and laptops that no application code touches.
Ask for them per request and they come back on the response, so your application can store them wherever it already stores anything.
# on the request X-Lyntway-Receipts: inline # on the response — base64 of the receipt JSON X-Lyntway-Receipt-Request-Body: eyJ2ZXJzaW9uIjoi... X-Lyntway-Receipt-Response-Body: eyJ2ZXJzaW9uIjoi...
Base64 because a header may not carry newlines and a receipt's JSON does. Two receipts because the request and the response are separate events with separate decisions.
Three headers come back whether or not you ask, and are enough to log which receipt to go and find later:
X-Lyntway-Request-Receipt: rcpt_046bdef1274e1191763b0ff4 X-Lyntway-Request-Decision: redact X-Lyntway-Chain: gw/openai
On a streamed response the response receipt cannot exist until the stream ends, so it arrives as the last event — with whether the stream was cut short, which a stream that merely stops cannot tell you:
event: lyntway
data: {"truncated":false,"receipt":"rcpt_046bdef..."}
When a value is substituted, the provider receives a token in the same format — a card number that is a valid card number, an address that is a valid address — so nothing downstream breaks on the shape of it.
What happens next depends on one setting, and it is the difference between an application that works and one that appears to mangle your data.
| Token vault on | Token vault off | |
|---|---|---|
| Your application receives | its own values, restored | tokens, permanently |
| The provider ever holds | tokens only | tokens only |
| We hold | the mappings | nothing |
| Resolvable later | yes, via /v1/detokenize | no, and never |
The middle row is the same either way. The vault decides what we hold, never what the provider holds.
With the vault on the round trip is automatic: the reply is turned back into your values before your application sees it, on streamed and buffered responses alike. Nothing to call, no code to add. A value the model produced that you never sent it is substituted rather than restored, because it is not yours coming home.
With the vault off, substitution is one-way for good. Suitable for "summarise this" and not for "send this to the customer". A deployment that wants to hold nothing at all should choose it deliberately, not discover it.
Tokens minted while the vault was off never become resolvable by turning it on: no mapping was written, and there is nothing to look up.
For content your application stored earlier, or received down a path we never saw. Resolving somebody's data is itself an event, so it produces a receipt naming who asked.
POST /v1/detokenize
{"chain_id": "gw/openai", "content": "Card 9999713846596148 ..."}
The chain scopes the lookup exactly as it scoped the substitution: a token
issued in one chain does not resolve in another. A response of
"resolved": 0 with your content unchanged means these were not
tokens this chain issued — usually the wrong chain_id, or
tokens minted while the vault was off.
Self-hosting is the stronger position, not a fallback. Your cloud, your keys, and nothing reaching us — which also removes every data-transfer question from your assessment.
The governing service is a static binary with no shell and no libc, and the root module has no third-party dependencies at all. The analyzer ships its models inside the image and is configured to make no outbound calls, so it runs in an air-gapped network.
Pricing · Testing brief · Terms · Privacy · Data processing · Verify a receipt