Saklam Bridge — Setup Guide
Drop-in LLM proxy with automatic PII masking. On-premises in your own infra, zero-knowledge towards Saklam, GDPR-compliant — sensitive data stays on-prem.
This guide walks you through installing and running the Saklam Bridge container inside your own network. You keep full data sovereignty: plaintext never leaves your infrastructure unmasked.
Architecture at a glance
App servers / Workstations / CI
Claude Code · Cursor · Your own apps
│
│ ANTHROPIC_BASE_URL=http://bridge.internal.example.com
│ ANTHROPIC_API_KEY=sk-bridge-<your-master-key>
▼
┌─ Saklam Bridge (Docker in your infra) ──────────────────────────┐
│ 1. Incoming request with sensitive plaintext │
│ 2. PII masking (350+ patterns, EU-27 + EEA) │
│ 3. Forward to Anthropic/OpenAI/Mistral with your API key (BYOK) │
│ 4. Unmask the response │
└──────────────────────────────────────────────────────────────────┘
│
│ Masked tokens, your API key
▼
Anthropic / OpenAI / Mistral
What Saklam sees: no content, ever. The only contact with the Saklam server is the license-token fetch (on startup, then periodically) — nothing but your license key leaves the host, never prompts or customer data. Verifiable: tcpdump on the container shows exactly two destinations — saklam.com (license) and your LLM provider.
What the LLM provider sees: masked tokens, never plaintext.
Requirements
| Component | Detail |
|---|---|
| Host OS | Linux x86_64 or ARM64 (the container is multi-arch). Windows/macOS via Docker Desktop. |
| Docker | Docker Engine ≥ 24 or Docker Desktop ≥ 4.30 with the docker compose v2 plugin |
| CPU/RAM | 1 vCPU + 2 GB RAM recommended (verified to run with 1 GB); 2 vCPU + 2 GB for lower latency / multi-user |
| Storage | ~1.5 GB disk (download ~0.45 GB) — the PII model (GLiNER, ONNX) ships pre-installed in the image, no runtime download |
| Network | Outbound HTTPS to api.anthropic.com (or your chosen provider) |
| API key | Pay-as-you-go API key from the LLM provider — not a Max/Pro/Team subscription OAuth token (see Auth note below) |
Quick install (5 minutes)
# 1. Create a directory
mkdir -p /opt/saklam-bridge && cd /opt/saklam-bridge
# 2. Download docker-compose.yml + .env.example
curl -fsSL https://saklam.com/bridge/docker-compose.yml -o docker-compose.yml
curl -fsSL https://saklam.com/bridge/env.example -o .env.example
# 3. Prepare .env
cp .env.example .env
$EDITOR .env
# - SAKLAM_LICENSE_KEY=sk-lic-… (required — from your activation email/dashboard)
# - ANTHROPIC_API_KEY=sk-ant-api03-… (pay-as-you-go key)
# - BRIDGE_MASTER_KEY=$(openssl rand -hex 32) (optional, see auth modes below)
# 4. Start
docker compose pull
docker compose up -d
# 5. Smoke test
sleep 5
curl -fsS http://localhost:4000/health/readiness
Auth modes (master key)
| Setup | Configuration | When |
|---|---|---|
| Single user on a laptop (Bridge used locally only) | BRIDGE_BIND_ADDR=127.0.0.1, BRIDGE_MASTER_KEY= (empty) |
Solo dev, dogfooding. The Bridge accepts any x-api-key. Safe because only host processes can reach it. |
| Multi-user server (central Bridge for the whole team) | BRIDGE_BIND_ADDR=0.0.0.0, BRIDGE_MASTER_KEY=<openssl rand -hex 32>, with a TLS reverse proxy in front |
Production deployment. Master key + TLS are both mandatory. |
Expected response: {"status":"connected"} (HTTP 200).
Tool integration
Claude Code (CLI)
export ANTHROPIC_BASE_URL=http://localhost:4000
export ANTHROPIC_API_KEY=<your BRIDGE_MASTER_KEY from .env>
claude
In the started Claude session, test any prompt containing plaintext PII:
> "Write a payment reminder to Max Mustermann, Musterstr. 12, 80331 Munich, amount due 1,245.50 €."
The Bridge masks Max Mustermann → [PER_a1b2c3d4], Musterstr. 12, 80331 Munich → [LOC_…], sends the masked version to Anthropic, and unmasks the response before returning it.
Cursor
Cursor Settings → Models → API Keys:
- Anthropic API Key:
<BRIDGE_MASTER_KEY> - Anthropic Base URL:
http://localhost:4000(orhttp://bridge.internal.example.com)
Your own scripts (Python anthropic SDK)
from anthropic import Anthropic
client = Anthropic(
base_url="http://localhost:4000",
api_key="<BRIDGE_MASTER_KEY>",
)
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Customer: Müller, case 2025/4711, amount 18k €."}]
)
n8n (self-hosted)
Dedicated guide with chat workflow, credential setup and how this relates to n8n's Guardrails node: Saklam Bridge + n8n. Short version: OpenAI credential with Base URL = http://saklam-bridge:4000/v1 — for Claude too (n8n's Anthropic credential has no base-URL field).
Multiple replicas behind a load balancer (v0.2.24 and later)
Placeholders are derived deterministically from salt, type and plaintext. The Bridge derives the salt from SAKLAM_LICENSE_KEY (HMAC-SHA256): same licence, same placeholders in every replica. The same name gets the same identifier in every container, and a multi-turn chat still finds its round-one placeholders when round two lands on a different replica.
- Several replicas, one licence: nothing to do.
- Different licences, shared identifiers: set the same
SAKLAM_SALTeverywhere (any string). - Neither a licence key nor
SAKLAM_SALT: identifiers are process-local and the Bridge logs a warning. Sticky sessions per user account are mandatory in that case.
The salt is as secret as the licence key, is never logged and never appears inside a placeholder. The placeholder memory (30 minutes, RAM) stays container-local: the identifiers match, the cache does not.
AWS Bedrock through an IAM role
IAM role: leave the variables out, do not leave them empty. An empty AWS_ACCESS_KEY_ID counts as set for LiteLLM and blocks the fallback to the AWS default credential chain (EC2 instance profile, ECS task role, IRSA). The shipped docker-compose.yml therefore keeps the three credential lines commented out; AWS_REGION_NAME stays set because it is not a credential. If you use static keys, uncomment the lines and put the values in your .env.
LAN access for team workstations
By default the Bridge binds to 127.0.0.1:4000. For network-wide access:
- In
.env:BRIDGE_BIND_ADDR=0.0.0.0 - Put a reverse proxy (nginx/Caddy/Traefik) with TLS in front — never plain HTTP over the LAN, because sensitive plaintext travels between the workstation and the Bridge.
- Restart with
docker compose up -d.
In-process: pip and npm
Same engine, same placeholder format, same license key, without a container:
pip install saklam and npm install saklam run detection and masking inside
your own process. The current version is 0.1.1 (10.09.2026).
Do not use 0.1.0: the model it bundled produced no AI detections at all on x86 CPUs without the VNNI instruction set (AMD EPYC/Zen 3–4, Intel before Ice Lake) and silently fell back to the regex layer. 0.1.1 fixes that.
| Bridge (Docker) | In-process package | |
|---|---|---|
| Form factor | gateway container in your infra | one dependency in your app |
| Setup | docker compose up -d |
pip install saklam / npm install saklam |
| Languages | any (it speaks HTTP) | Python ≥ 3.10, Node ≥ 20 |
| Platforms | Linux host with Docker (amd64, arm64) | macOS arm64, Linux amd64/arm64, Windows amd64 |
| Roundtrip | mask, call the provider, unmask, all inside the gateway | you call mask() before your own LLM call, unmask() after it |
| Provider keys | BYOK in the container | your own client code; Saklam is not in the call at all |
| License | sk-lic-…, €99 / month |
the same key, the same €99 / month |
Take the Bridge when the masking has to sit in front of an existing tool (Claude Code, Cursor, n8n, a whole team) or when your stack is neither Python nor Node. Take the package when you are writing the code that talks to the model anyway and would rather not operate a container.
Install
pip install saklam # Python ≥ 3.10
npm install saklam # Node ≥ 20
# verified on macOS arm64, Linux amd64/arm64, Windows amd64
Three lines, Python
from saklam import Saklam
pii = Saklam(license_key="sk-lic-…") # or SAKLAM_LICENSE_KEY
r = pii.mask("Philip Müller aus Zürich, philip@example.org")
r.masked # '[PER_1864ad4c] aus [LOC_2350dca1], [EMA_c85d5952]'
pii.unmask(r.masked, r.mapping) # plaintext back
Three lines, Node
import { Saklam } from 'saklam';
const s = new Saklam({ licenseKey: process.env.SAKLAM_LICENSE_KEY });
await s.ready();
const { masked, mapping } = await s.mask(
'Philip Müller aus Zürich, philip@example.org'
);
// masked → '[PER_651b852a] aus [LOC_ae6c7a68], [EMA_203cfcd0]'
const answer = await llm(masked); // masked text goes out
const plaintext = s.unmask(answer, mapping); // plaintext comes back
Both packages also install a saklam command:
saklam mask "Philip Müller, philip@example.org"
saklam detect "…" --json
saklam license
Models
| Model | Size | Where it comes from |
|---|---|---|
bundled (npm) / small (pip, wheel saklam-model-de-small) |
83.7 MiB | inside the package; works offline right after install |
default |
237.9 MiB | license-bound download from saklam.com/v1/models/ on first use, cached in ~/.cache/saklam/models/<model_id>/ |
Since 0.1.1 the bundled model is U8U8-quantized (87,791,681 bytes), which
computes correctly on x86-64 with and without the VNNI instruction set;
default is an fp32 build. Verified platforms: macOS arm64, Linux amd64 and
arm64, Windows amd64 — proven by the CI matrix on real hosts (GitHub Actions
ubuntu/macos/windows, including AMD EPYC runners without VNNI). The download for
default needs a valid license token, is checked against checksums pinned in
the package, and is moved into the cache atomically.
Self-test at startup: once the AI model is loaded, the package runs one
fixed control sentence through detection. If no person comes back, startup
fails loudly (SelfTestError in Python, ready() throws in Node, CLI exit
code 4) instead of quietly continuing with half the detection. Escape hatch for
deliberate special cases: SAKLAM_SKIP_SELFTEST=1.
If no model can be loaded, the packages do not fail: they keep running with the
regex layer alone and say so (ready().mode in Node, MaskResult.engine and
status() in Python). The regex layer finds structured PII (email, phone,
IBAN, tax IDs, ID and card numbers, addresses), but no free names, locations or
organizations.
Air-gapped hosts: put a prepared model directory on disk and point
SAKLAM_MODEL_DIR at it (model_dir= in Python, modelDir in Node). Nothing
is downloaded then.
License and zero-knowledge
- The key is the same
sk-lic-…that the Bridge uses, from your activation email or the dashboard. One key covers every form factor: Bridge, pip, npm. Pass it to the constructor or setSAKLAM_LICENSE_KEY. - It is exchanged once for a short-lived, Ed25519-signed token
(
POST https://saklam.com/v1/license/token) and cached in~/.cache/saklam/license/<sha256(key)[:16]>.token. Every later call verifies that token offline against the public key embedded in the package. No phone-home per call. If saklam.com is unreachable, the cached token stays valid until itsexp(grace period). pip and npm use the same cache file, so one activation serves both. - File permissions on macOS and Linux only: there the token file is
0600inside a0700directory. Windows has no such bits — there the file lives inC:\Users\<name>\.cache\saklam\like any other file in the user profile and inherits its permissions; the recommendation there is profile permissions and BitLocker. - Only the key leaves the host, plus two operational fields:
client(pipornpm) andclient_version(the package version). Never text, never detections, never hostnames or usage counters. - Hard gate: without a valid token,
detect,maskandunmaskrefuse to work (LicenseError, CLI exit code 2). There is no env switch that turns the check off. The keyless 7-day trial of the browser extension does not apply here; these packages want a real key.
Footprint
pip saklam |
npm saklam |
|
|---|---|---|
| Size | ~300 MB installed including the model (macOS 295 MB, linux/amd64 321 MB, linux/arm64 310 MB), no torch, no CUDA | 96.8 MB unpacked, 77.3 MB tarball |
| Dependencies | onnxruntime, tokenizers, numpy, pydantic, pyyaml, cryptography |
onnxruntime-node (pinned to 1.29.0), nothing else |
| Runtime | Python ≥ 3.10 | Node ≥ 20 |
| Platforms | macOS arm64, Linux amd64/arm64, Windows amd64 | macOS arm64, Linux amd64/arm64, Windows amd64 |
Loading the model takes 1 to 4 seconds once; after that a short sentence costs roughly 20 to 25 ms (macOS arm64).
The honest price of the fix: on x86 CPUs with VNNI the U8U8 build costs about twice the AI compute time of 0.1.0 (measured on a Xeon: 37 → 72 ms for three texts). On arm64 and on x86 without VNNI the difference is noise. A build that finds nothing at all on part of the fleet is a correctness problem, not a speed problem.
Limits
- The packages run a distilled student model, the Bridge runs its production model. Spans can differ at the edges (the Bridge pulls a salutation into the person span, the package starts at the title). The name itself is masked either way.
- Healthcare texts stay with the Bridge: on clinical material the student models over-mask (drug names as PERSON). No healthcare promise for pip/npm.
- The packages mask in your process, but they do not talk to any provider. Routing, streaming, audit trail and the hybrid setup are Bridge features.
Mask API: /v1/mask and /v1/unmask
Use this when you want masking without the proxy: your app calls /v1/mask,
sends the masked text to any LLM, and passes the answer to /v1/unmask. You
get the mapping back from /v1/mask and send it along with /v1/unmask.
Auth. If BRIDGE_MASTER_KEY is set in .env, every call to /v1/mask,
/v1/mask/pdf and /v1/unmask needs the key, either as
Authorization: Bearer <key> or as x-api-key: <key>. A missing or wrong key
returns 401 (UNAUTHORIZED). With an empty BRIDGE_MASTER_KEY the API is
open (see Auth modes).
POST /v1/mask
Request (JSON):
| Field | Required | Meaning |
|---|---|---|
text |
yes | Clear text, 1 to 100,000 characters |
language |
no | Language of the text, default de |
entity_types |
no | Allow-list of at most 64 entries. Only these types are masked. Without the field, everything is masked. |
Response (JSON):
| Field | Meaning |
|---|---|
masked |
Text with placeholders in the form [TYP_xxxxxxxx]: three capital letters, an underscore, eight hex characters, e.g. [PER_…], [EMA_…] |
mapping |
Object placeholder → original value. You need it for /v1/unmask. |
entities_detected |
One entry per hit: type, placeholder, confidence (0 to 1), position as [start, end] in the original text |
detection_confidence |
Mean of the confidences, 1.0 if nothing was found |
Errors:
| Status | When |
|---|---|
401 |
Master key is set but missing or wrong (UNAUTHORIZED) |
422 |
Invalid request: text empty or longer than 100,000 characters, more than 64 entity_types |
429 |
No detection slot free within the wait time (CAPACITY_EXCEEDED). The Retry-After header gives seconds. Wait that long, then retry. |
500 |
Detection failed (MASKING_ERROR) |
A 429 means no detection took place. How many calls run at once and how long they wait is covered in Load, limits and backpressure.
POST /v1/unmask
Request (JSON): text (the text with placeholders, 1 to 100,000 characters)
and mapping (the object from /v1/mask). Response: unmasked, the text with
the original values.
The Bridge replaces every placeholder listed in mapping with its original
value. This is plain text replacement without detection, so there is no 429
here. Placeholders that are not in mapping stay as they are.
POST /v1/mask/pdf
Upload a PDF (multipart, field file, optional language and comma-separated
entity_types). The response has the same fields as /v1/mask plus pages
and chars; the mapping works with /v1/unmask. Details, limits and error
codes are under Attachments.
GET /v1/mask/health
Detection status, no auth required: status, detector_loaded, plus
capacity (utilisation and rejections) and cache (hits, entries). The values
are explained under Load.
Example: curl
Needs curl and jq. The mapping ends up in mask.json and never leaves your
machine.
export BRIDGE_MASTER_KEY='your-value-from-env' # leave empty if you run without a master key
# 1. Mask
curl -s http://localhost:4000/v1/mask \
-H "Content-Type: application/json" \
-H "x-api-key: $BRIDGE_MASTER_KEY" \
-d '{"text": "Please call back Anna Schmidt, anna.schmidt@example.com.", "language": "en"}' \
> mask.json
jq -r .masked mask.json
# 2. Send the masked text to the LLM of your choice.
# Simplified here: the LLM answer is the masked text itself.
# 3. Unmask: text plus the mapping from step 1
jq '{text: .masked, mapping: .mapping}' mask.json \
| curl -s http://localhost:4000/v1/unmask \
-H "Content-Type: application/json" \
-H "x-api-key: $BRIDGE_MASTER_KEY" \
-d @-
Example: Python with requests
import os
import time
import requests
BRIDGE = "http://localhost:4000"
HEADERS = {"x-api-key": os.environ.get("BRIDGE_MASTER_KEY", "")}
def post(path, payload, tries=5):
for _ in range(tries):
r = requests.post(f"{BRIDGE}{path}", json=payload, headers=HEADERS, timeout=180)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "10")))
continue
r.raise_for_status()
return r.json()
raise RuntimeError("Bridge busy, try again later")
masked = post("/v1/mask", {
"text": "Please call back Anna Schmidt, anna.schmidt@example.com.",
"language": "en",
})
print(masked["masked"])
# Send masked["masked"] to any LLM here.
llm_answer = masked["masked"]
result = post("/v1/unmask", {"text": llm_answer, "mapping": masked["mapping"]})
print(result["unmasked"])
Cache. Since v0.2.27 the Bridge keeps results per text and per paragraph.
The same text with the same language and the same allow-list is served from
the cache without running detection again. Details under Load.
entity_types. The allow-list masks only the types you need, e.g.
["EMA", "IBA"]. The accepted names are listed under
Per-call allow-list.
Load, limits and backpressure (v0.2.26 and later)
Detection runs on the CPU. Reference values without load, measured with the same container image:
| Text | Apple M1 / x86 with AVX2 | virtual CPU without AVX |
|---|---|---|
| 2,000 characters | 0.7 s | 4 s |
| 10,000 characters | 3.9 s | 29 s |
| 30,000 characters | 11.9 s | 130 s |
Three consequences for integrations:
- Mask only the payload, not the whole prompt with instructions and templates. The lead record or the mail body is usually under 2,000 characters.
- Call sequentially, or with at most as many parallel calls as the container allows (see below). Detection uses 2 threads per call; more cores do not speed up a single call, only several at once.
- Server size: 2 cores per concurrent call plus one, 4 GB RAM (the
container uses 0.6 GB idle, up to 1.5 GB under load). The CPU class matters
most: x86-64 with AVX2 or better AVX-512, or ARM64. No shared-vCPU instance
and no VM that reports itself as "QEMU Virtual CPU"; check with
grep -c avx2 /proc/cpuinfo, which must be greater than 0 (not needed on ARM).
Backpressure instead of hanging. Up to v0.2.25 the Bridge accepted any
number of concurrent calls; under load the queue grew until no request finished
within any deadline. Since v0.2.26 at most SAKLAM_MAX_CONCURRENT_MASK
detections run at once (default 2, proxy path and Mask REST combined). A call
that gets no slot waits at most SAKLAM_MASK_QUEUE_SECONDS (default 15) and
then receives HTTP 429 with Retry-After (default 10 s):
{"error": "capacity_exceeded", "code": "CAPACITY_EXCEEDED", "retry_after": 10,
"in_flight": 2, "max_concurrent": 2, "message": "…"}
On 429 no detection has taken place and nothing has been forwarded unmasked.
Clients wait the Retry-After seconds and retry (backoff 5, 10, 20, 40, 60 s).
Live utilisation: GET /v1/mask/health → capacity with in_flight,
waiting, rejected, avg_seconds.
Two caches instead of repeated work (v0.2.27 and later). Chat clients send the
whole history plus system prompt on every turn. The Bridge remembers the detection
result per exact text (text cache) and per paragraph (paragraph cache), so a fixed
prompt template plus a variable part only costs the variable part. Measured: a
10-turn chat with 20,000 characters of context 66 s → 7 s; a 5,700-character
template plus a 2,000-character lead 1.06 s → 0.26 s per call. Both caches live
only in the container's memory (TTL 1 h, 64 MB cap), never on disk, and hold clear
text plus mapping like the placeholder memory used for unmasking.
SAKLAM_MASK_CACHE=0 disables both, SAKLAM_SEGMENT_CACHE=0 only the paragraph
cache. If you only need email, phone or IBAN, say so in entity_types: the model
is then skipped entirely (about 20× faster).
Per-call allow-list. entity_types in POST /v1/mask (JSON array) and
POST /v1/mask/pdf (form field, comma-separated) restricts masking to the
listed types: full type name (PERSON, EMAIL, GERMAN_IBAN), type without
country prefix (IBAN) or placeholder prefix (PER, EMA, PHO, IBA,
STR, LOC, ORG). Without the field everything is masked as before.
curl -s http://localhost:4000/v1/mask \
-H "Content-Type: application/json" -H "x-api-key: $BRIDGE_MASTER_KEY" \
-d '{"text": "…", "entity_types": ["PER", "EMA", "PHO", "IBA", "STR", "LOC", "ORG"]}'
Attachments: PDF, images, files (v0.2.25 and later)
The engine masks text. When a client attaches a file as its own content block
(PDF document, image, file upload), SAKLAM_ATTACHMENTS decides what the
Bridge does with it. Up to v0.2.24 such blocks went to the provider unchanged
and without notice.
| Mode | PDF with a text layer, text files | Images, scans without a text layer, encrypted PDFs, file references |
|---|---|---|
extract (default) |
Text is extracted, masked and forwarded as a text block. Layout and images are dropped, the content arrives. | Request is rejected with a clear error message. |
reject |
rejected | rejected |
pass |
forwarded unchanged, unmasked | forwarded unchanged, unmasked |
- No OCR. Scanned PDFs need OCR on your side; send the text afterwards.
- The file name is not forwarded to the model (it often carries a name itself, and the engine cannot reliably detect names inside file names).
- Recognised: Anthropic
documentandimage, OpenAIfile,image_urlandinput_audio, Responses APIinput_fileandinput_image. References (file_id, URL) cannot be fetched by the Bridge and are rejected. - Size cap per PDF:
SAKLAM_ATTACHMENT_MAX_MB(default 32). - Long documents take time. A 9-page tax assessment yields about 24,000 characters; masking takes seconds to minutes on a small instance.
- Without the proxy:
POST /v1/mask/pdf(multipart, fieldfile, optionallanguage) returns the masked text with page markers plus the mapping for/v1/unmask(see Mask API). Scan or encrypted:422 ATTACHMENT_REJECTED. passis a deliberate operator decision and is printed at startup. If you must send images to the model, you choose it knowingly.
curl -s http://localhost:4000/v1/mask/pdf \
-H "x-api-key: $BRIDGE_MASTER_KEY" \
-F "file=@case.pdf" -F "language=de"
What chat front-ends do with files is up to the front-end: Open WebUI puts the
text of uploaded files into the system prompt (masked since v0.2.23), Langdock
extracts PDF text and additionally sends page screenshots (text masked,
screenshots rejected from v0.2.25 on, or forwarded unmasked with pass).
Custom detection
The Bridge ships with 335+ built-in PII patterns. Organization-specific
sensitive data — project names, case numbers, domain terms — goes into a
./custom folder next to your docker-compose.yml (uncomment the custom
volume in the compose file, restart the container). No code, no training,
no image rebuild. Three file kinds:
| File | Purpose | Example |
|---|---|---|
*.txt |
term list, 1 line = 1 term; filename = category | projects.txt containing "Project Phoenix" → [PRO_…] |
*.yaml |
own regex patterns for fixed formats | case number WUB-\d{6} → [VOR_…] |
labels.yaml |
own AI entities, zero-shot, no training | medication: MEDICATION catches "Pantoprazole" |
# custom/labels.yaml
labels:
medication: MEDICATION
diagnosis: DIAGNOSIS
ner_threshold: 0.5 # optional; higher = stricter
Everything is additive to the built-in patterns and flows through the same mask/unmask roundtrip and audit trail. Note: with AI entities configured, every request runs the full NER analysis (~1–2 s/request on CPU) — term lists and regex patterns don't have that effect. Test zero-shot labels with real sample texts before production; for enumerable terms, a term list is always the more precise choice.
Medication guard (opt-in, since v0.2.18)
For clinical, practice and discharge-letter texts: the detector occasionally treats drug names (Ramipril, Xarelto, Rocephin …) as persons. The medication guard drops such hits against a gazetteer of 5,746 names (active substances from ATC/Wikidata plus DACH brand names). It detects nothing new and never touches regex hits or salutations ("Frau Dr. med. …").
Enable it in .env, then recreate the container (docker compose up -d):
SAKLAM_DOMAIN_MODULES=klinik,kanzlei,generisch,kern,medneg
The start log then shows Arzneimittel-Wache (medneg): AN (5746 Namen).
Without the line, behaviour is unchanged. The whole deterministic domain
layer can be switched off with SAKLAM_DOMAIN_LAYER=0.
Updates
cd /opt/saklam-bridge
docker compose pull
docker compose up -d
We recommend a weekly maintenance window or one tied to Saklam release notes. Patch releases are compatible; major versions are announced on the release channel.
Auth note (important before setup)
Saklam Bridge works only with classic API keys (pay-as-you-go), not with subscription OAuth.
Does not work:
- Anthropic Max / Pro / Team OAuth subscription tokens
- ChatGPT Plus / Team web sessions
Supported providers (all BYOK = Bring Your Own Key, Saklam does not act as a reseller):
| Provider | When it makes sense | Where to get it |
|---|---|---|
| Anthropic (direct) | Frontier models (Claude Sonnet/Opus/Haiku) | console.anthropic.com → API Keys |
| OpenAI (direct) | GPT models | platform.openai.com → API Keys |
| Azure OpenAI | If you have an M365 enterprise contract — same GPT models with EU data residency + Microsoft DPA | Azure Portal → AI Services → Azure OpenAI |
| Google Gemini (Vertex AI) | Gemini family, EU region Frankfurt available | aistudio.google.com or Vertex AI |
| AWS Bedrock | If you have an AWS contract — Claude / Llama / Mistral through one API with an AWS DPA, EU Frankfurt (eu-central-1) |
AWS IAM → Access Key + Bedrock Model Access |
| Mistral (direct) | EU provider La Plateforme | console.mistral.ai |
| Self-hosted | Your own inference cluster (Ollama / vLLM / TGI) — maximum zero-knowledge | OLLAMA_API_BASE=http://ollama.internal:11434/v1 |
You pick one or more providers and add the corresponding env variables to .env. Only ANTHROPIC_API_KEY is required (the default provider on first start) — everything else is optional. Config routing happens automatically based on the model-name prefix:
claude-*→ Anthropicgpt-*/openai/*→ OpenAI (direct)azure/*→ Azure OpenAIgemini/*→ Google Geminibedrock/*→ AWS Bedrockmistral/*→ Mistralollama/*→ Self-hosted
Hybrid routing: local model + masked frontier (one gateway)
You don't have to choose between "local model" and "frontier quality" — the Bridge routes both:
- Routine tasks → local model (
ollama/…): the request never leaves your host. Summaries, classification, simple drafts. - Complex tasks → frontier, masked (
claude-…,gpt-…): PII is replaced before the request goes out, the response is unmasked locally.
Both paths go through the same masking and the same audit trail — one policy, one record, regardless of model.
Run Ollama inside the same compose stack (optional profile):
# .env: OLLAMA_API_BASE=http://ollama:11434/v1
docker compose --profile local-llm up -d
docker compose exec ollama ollama pull llama3.2
Call it like any other model, just with the ollama/ prefix (OpenAI wire):
curl http://localhost:4000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "ollama/llama3.2", "messages": [{"role": "user", "content": "…"}]}'
If Ollama already runs elsewhere on your network, OLLAMA_API_BASE=http://ollama.internal:11434/v1 is all you need — the profile is just the compose convenience variant. Set expectations honestly: local models on CPU servers are noticeably slower and weaker than frontier — the hybrid pattern deliberately reads "routine locally, complex masked to frontier", not "local does everything".
FAQ
Do the [XXX_yyyyyyyy] placeholders confuse the LLM or make it refuse to answer?
No. The Bridge automatically injects a system instruction into every request (wire-aware for both the Anthropic and OpenAI formats) that explains to the LLM: tokens in the format [PER_a1b2c3d4], [EMA_…], [TEL_…], etc. are masked personal data — copy them verbatim, don't change them, don't invent new ones, and treat them "as if they were the real data."
For normal tasks (summarizing, drafting text, email drafts, analysis) output quality is fully preserved — the placeholder stands semantically in place of the real person/IBAN/address. In the response, the placeholders are automatically unmasked before they reach your app/client: you see the real values, the LLM never does.
Intentional edge case: for tasks that need the exact original value of a masked field — e.g. "verify the checksum of this IBAN" or "how many digits does this phone number have?" — the LLM correctly notes that it only sees a placeholder. Such format/validation checks belong on your side (before or after the Bridge), not inside the masked AI request.
Handy side effect for your own testing: if you ask the LLM whether a masked field is "real," it replies "that's a placeholder" — direct, visible proof that plaintext never reaches the provider.
What happens to PDFs and images I send along?
Since v0.2.25: PDFs with a text layer are extracted as text, masked and passed
to the model that way. Images and scanned PDFs cannot be read by the Bridge (no
OCR) and are rejected instead of being forwarded unmasked. If you need images,
set SAKLAM_ATTACHMENTS=pass and know that those blocks reach the provider in
clear text. Details in the "Attachments" section.
Can I just mask, without the proxy?
Yes. POST /v1/mask returns the masked text and the mapping. You send the text
to any LLM and get the original values back with POST /v1/unmask. Fields,
error codes and examples are in Mask API.
Troubleshooting
unhealthy in docker ps
docker compose logs bridge
Typical causes:
- First ~10–30s after start: the GLiNER model is loaded from the image into RAM (warmup, no download — the model is in the image) —
start_period: 60sin the healthcheck covers this. If it's stillunhealthyafter 90s: check the logs. ANTHROPIC_API_KEYinvalid → 401 from Anthropic → the Bridge reports no error at start, but every request fails. Test:curl …/health/liveness(should be 200 even without Anthropic).
401 Unauthorized on Anthropic calls
Your API key is a Max/Team OAuth token instead of a classic sk-ant-… key. See the Auth note above.
Latency
The model is pre-installed in the image and loaded into RAM on container start (no runtime download, air-gap capable). The first PII request is therefore not delayed by a download. Duration depends on text length and CPU class: roughly 0.4 s per 1,000 characters on a current core (AVX2 or ARM64), several times that on virtual CPUs without AVX. Reference values, server sizing and the 429 backpressure: section "Load, limits and backpressure".
PII is not being masked
Check the logs: docker compose logs bridge | grep -i 'mask'. Common causes:
MASK_API_URLis set but the target is unreachable → remove the variable from.env. The default is in-process (no external mask service, no network hop).- The prompt only contains domain-specific abbreviations that aren't in the 350+ patterns → custom patterns on request.
"Disclosure": the Bridge buffers PII while processing a request
Yes, this is architecturally unavoidable (RAM-only, no disk persistence). A malicious container admin could inspect the process. That's exactly why the Bridge belongs in your infra, not at Saklam.
no valid license / requests rejected with 5xx
The Bridge needs a valid license (active subscription). Check:
SAKLAM_LICENSE_KEYset correctly in.env(sk-lic-…from your activation email/dashboard)?- Subscription active? See your dashboard.
- On startup the Bridge automatically fetches a signed license token (only the key leaves the host — never customer data). Logs:
docker compose logs bridge | grep -i licen. - Network is needed once to activate; afterwards a cached token bridges short outages (grace period).
Logs & privacy
- Default logging: structured JSON logs (
json-filedriver), rotated at 50 MB. - PII in logs: the Bridge logs request metadata (model, token counts, latency), not prompt plaintext.
- External log forwarding (Splunk, ELK, Loki): standard Docker logging mechanisms.
- Auditability: audit-trail export (CSV) + tamper-evident hashing on request — coming in Phase 2 (see roadmap).
Pricing & license
| Plan | Price | Includes |
|---|---|---|
| Solo | €99 / month or €990 / year (2 months free), plus VAT | 1 user, unlimited instances and volume, BYOK, auto-updates, pattern maintenance, email support, cancel monthly |
The same key works in every form factor: Bridge, pip install saklam and
npm install saklam (see In-process: pip and npm). We count users,
not containers, so the number of instances is unlimited in every tier.
A team, or more than one user? Write me (stefan@saklam.com) — we'll sort it out individually.
Data processing: Saklam does not act as a data processor, because Saklam never receives personal data — the Bridge runs on-premises in your infrastructure. We provide a site-license agreement + a technical whitepaper for your data-protection documentation.
Support
| Channel | Detail |
|---|---|
| support@saklam.com |
Setup help: hands-on with Stefan over a screen share if you want it — you're live in 15–20 min.