Command-line client for resources protected by ZETA Guard. Handles the full OAuth2 client lifecycle — dynamic client registration, SMC-B authentication via a gematik Konnektor or local PKCS#12 keystore, token refresh and revocation — and ships a curl-like HTTP client and a JSON-over-WebSocket client that transparently attach the bearer token to every request. Also obtains Proof-of-Patient-Presence (PoPP) tokens via Konnektor-driven eGK flows.
- Quick start
- Commands
- Options reference
- Configuration file:
zeta.yaml - Logging & output
- SDK version
- Install
- Development
For a full end-to-end walkthrough (configure the .kon, read the SMC-B ICCSN, log in, get a
PoPP token), see docs/quickstart.md.
# 1. See what a Zeta-Guard-protected resource advertises (no auth needed)
zeta discover https://popp.dev.poppservice.de
# 2. Log in (register + authenticate) using your SMC-B via the Konnektor
zeta login https://popp.dev.poppservice.de \
--scope popp \
--auth-method connector \
--auth-connector-telematik-id 5-2-1234567
# 3. Check local SDK state
zeta status
# 4. Call the protected resource — the bearer token is added for you
zeta http https://popp.dev.poppservice.de/some/api --scope popp
# 5. Get a PoPP token by driving the Konnektor's eGK
zeta popp connectorFor headless / no-Konnektor environments, substitute the auth options:
zeta login https://popp.dev.poppservice.de \
--scope popp \
--auth-method p12 \
--auth-p12-file ./smcb.p12(--auth-p12-alias defaults to alias, --auth-p12-password to 00 — see the PKCS#12 method table.)
Lifecycle & state
zeta discover URL— fetch + cache protected-resource and AS metadata (RFC 9728). No auth.zeta status [URL]— show cached SDK state (registration + token validity). Read-only.zeta register URL— dynamic client registration with the AS.zeta authenticate URL --scope …— exchange SMC-B subject token → access + refresh token.zeta login URL --scope …— idempotentregister+authenticate.zeta logout URL— revoke tokens; keep client registration.zeta forget URL/--all— wipe local state for one resource, or the whole profile.
Calling protected resources
zeta http URL— HTTP request to a Zeta-protected resource (curl-like).zeta ws URL— WebSocket to a Zeta-protected resource, round-tripping JSON from stdin.zeta vsdm [POPP-TOKEN]— read a patient's VSDM bundle straight from a PoPP token: derives the environment and insurer from the token, resolves the VSDM endpoint via the TI service-discovery catalog, and fetches the bundle (scopevsdservice). Token falls back toZETA_POPP_TOKEN.
Konnektor & PoPP
zeta connector inspect— SDS / product info from the Konnektor.zeta connector get cards— list cards visible in the Konnektor's terminals.zeta connector configs— list discoverable.konfiles.zeta connector use <name>— set the default.konconfig for later commands.zeta popp connector— get a PoPP token via the Konnektor's eGK flow.zeta popp kartos— get a PoPP token via a kartos smartcard simulator.
Tooling
zeta version— print the CLI andzeta-sdkversion.zeta stress …— load-test ZETA Guard with a fleet of SMC-B-backed clients. See docs/stress.md.
Examples for the less obvious ones:
# Status of a single resource as JSON (good for jq pipelines)
zeta status https://popp.dev.poppservice.de -o json | jq
# HTTP POST with body and custom header
zeta http https://api.example.org/v1/foo \
--scope foo -X POST -d '{"x":1}' -H 'X-Trace-Id: dev-42'
# WebSocket with PoPP header inlined; messages from stdin
zeta ws wss://service.example.org/stream --scope foo --popp-token "$TOKEN" <<<'{"hello":1}'
# Same in PowerShell — note the $env: prefix; bare $ZETA_POPP_TOKEN won't reach the JVM
# $env:ZETA_POPP_TOKEN = (zeta popp connector)
# zeta http https://api.example.org/v1/foo --scope foo
# List .kon files the CLI can resolve by short name
zeta connector configs
# Wipe everything in a profile (asks for confirmation on a TTY; --force skips it)
zeta forget --all --profile stagingAll options below are sticky — they're accepted at any depth: zeta -v http … and zeta http -v … both work. The same goes for -c/--connector-config, --profile, etc.
Values resolve in this precedence (high → low):
CLI flag → environment variable → zeta.yaml → built-in default.
PowerShell users:
$ZETA_POPP_TOKEN = …sets a PowerShell variable that child processes never see. Use$env:ZETA_POPP_TOKEN = …(same for any otherZETA_*env var) so the JVM picks it up.
Available on every command.
| Option | Env var | Default |
|---|---|---|
-v, --verbose (repeatable) |
— | warn (0). -v=info, -vv=debug, -vvv=trace |
--connect-timeout=<seconds> |
ZETA_CONNECT_TIMEOUT |
5 |
--request-timeout=<seconds> |
ZETA_REQUEST_TIMEOUT |
30 |
-k, --insecure |
ZETA_INSECURE |
false (TLS verified) |
--asl-prod |
ZETA_ASL_PROD |
false (non-prod) |
--ca-cert=<file> (repeatable) |
ZETA_CA_CERT |
JVM default trust store |
-o, --output-format=text|json|raw |
ZETA_OUTPUT_FORMAT |
text |
-f, --file=<file> |
ZETA_CONFIG |
auto-discovered zeta.yaml |
--no-config |
ZETA_NO_CONFIG |
false (auto-discovery on) |
-c, --connector-config=<name> |
ZETA_CONNECTOR_CONFIG |
default |
--proxy=<url> |
ZETA_PROXY |
— |
--proxy-user=<user> |
ZETA_PROXY_USER |
— |
--proxy-password=<password> |
ZETA_PROXY_PASSWORD |
— |
--trace — print in-process span tree at end of command |
ZETA_TRACE |
false |
-v is the one exception to the env-var rule: Clikt's repeat-count flag doesn't pair with a single env value. Use -v/-vv/-vvv on the CLI, or set verbose: in zeta.yaml.
--proxy accepts http[s]://[user:pass@]host[:port]. Use --proxy-user / --proxy-password to keep credentials out of the URL.
State (registrations, tokens) is namespaced by profile. Persisted as $XDG_CONFIG_HOME/telematik/zeta/<profile>.storage.json.
Available on: discover, status, register, authenticate, login, logout, forget, http, ws, vsdm, popp ….
| Option | Env var | Default |
|---|---|---|
--profile=<name> |
ZETA_PROFILE |
default |
Required by: register, authenticate, login, logout, http, ws, vsdm, popp ….
Pick a method via --auth-method, then supply that method's options.
| Option | Env var | Default |
|---|---|---|
--auth-method=connector|p12|db |
ZETA_AUTH_METHOD |
required |
Signs the SMC-B token via a Konnektor described by --connector-config. Pick exactly one card identifier (stability order, best → worst):
| Option | Env var | Default |
|---|---|---|
--auth-connector-telematik-id=<tid> |
ZETA_AUTH_CONNECTOR_TELEMATIK_ID |
— |
--auth-connector-card-iccsn=<iccsn> |
ZETA_AUTH_CONNECTOR_CARD_ICCSN |
— |
--auth-connector-card-handle=<handle> |
ZETA_AUTH_CONNECTOR_CARD_HANDLE |
— |
The Konnektor itself is described by a .kon file (JSON). The CLI resolves
--connector-config=<name> (env ZETA_CONNECTOR_CONFIG) to <name>.kon, looking in the
current directory first, then $XDG_CONFIG_HOME/telematik/connectors/. zeta connector use <name> records a sticky default so later commands can omit the flag, and zeta connector configs lists the files it can resolve by short name.
${VAR} placeholders are substituted from the environment before the file is parsed, so
secrets stay out of the file. See docs/kon-format.md for the full
format specification.
mTLS via a PKCS#12 client certificate, with a pinned trust anchor (RU):
{
"url": "https://konnektor.example.com:8443",
"mandantId": "M1",
"workplaceId": "W1",
"clientSystemId": "C1",
"userId": "U1",
"credentials": {
"type": "pkcs12",
"data": "<base64-encoded PKCS#12 container>",
"password": "00"
},
"env": "ru",
"expectedHost": "konnektor.example.com",
"trustStore": ["${KONNEKTOR_CA_DER_BASE64}"]
}Dev Konnektor over basic auth (RU), TLS verification off for self-signed setups:
{
"url": "https://10.10.10.5:443",
"mandantId": "M1",
"workplaceId": "W1",
"clientSystemId": "C1",
"credentials": {
"type": "basic",
"username": "konnektor",
"password": "${KONNEKTOR_PASSWORD}"
},
"env": "ru",
"insecureSkipVerify": true
}For headless / no-Konnektor environments. Signs locally with a .p12 keystore.
| Option | Env var | Default |
|---|---|---|
--auth-p12-file=<file> |
ZETA_AUTH_P12_FILE |
required |
--auth-p12-alias=<name> |
ZETA_AUTH_P12_ALIAS |
alias |
--auth-p12-password=<password> |
ZETA_AUTH_P12_PASSWORD |
00 |
Signs with an SMC-B identity from a zeta-stress identity database (SQLite, built by zeta stress import-identities). For headless / bulk runs.
| Option | Env var | Default |
|---|---|---|
--auth-db=<file> |
ZETA_AUTH_DB |
stress.db |
--auth-db-telematik-id=<tid> |
ZETA_AUTH_DB_TELEMATIK_ID |
required — except zeta vsdm, which derives it from the token's actorId |
| Option | Env var | Default |
|---|---|---|
-s, --scope=<name> (repeatable, required) — authenticate / login only |
ZETA_SCOPE |
— |
--reveal — include redacted secrets in status output |
ZETA_REVEAL |
false |
| Option | Env var | Default |
|---|---|---|
--all — wipe entire profile |
ZETA_FORGET_ALL |
false |
--force — skip interactive confirmation |
ZETA_FORGET_FORCE |
false |
| Option | Env var | Default |
|---|---|---|
-X, --request=<method> |
ZETA_HTTP_METHOD |
GET (or POST if -d set) |
-H, --header=<name: value> (repeatable) |
ZETA_HTTP_HEADER |
— |
-d, --data=<body> |
ZETA_HTTP_DATA |
— |
-i, --include — print status + headers |
ZETA_HTTP_INCLUDE |
false |
-s, --scope=<name> (repeatable, required) |
ZETA_SCOPE |
— |
-p, --popp-token=<token> |
ZETA_POPP_TOKEN |
— |
| Option | Env var | Default |
|---|---|---|
-H, --header=<name: value> (repeatable) |
ZETA_WS_HEADER |
— |
-s, --scope=<name> (repeatable, required) |
ZETA_SCOPE |
— |
-p, --popp-token=<token> |
ZETA_POPP_TOKEN |
— |
| Option | Env var | Default |
|---|---|---|
POPP-TOKEN (positional, optional) |
ZETA_POPP_TOKEN |
— |
Everything else is derived from the token: the environment (from the issuer), the insurer's VSDM endpoint (from the TI service-discovery catalog), the vsdservice scope, and — for --auth-method db — the signing identity (from the token's actorId). Supply a profile and an auth method as for zeta http.
| Option | Env var | Default |
|---|---|---|
--service-url=<url> |
ZETA_POPP_SERVICE_URL |
popp dev service URL |
--connection=contact|contactless |
ZETA_POPP_CONNECTION |
contact |
EGK_HANDLE is positional and optional — auto-picked when exactly one eGK is visible.
| Option | Env var | Default |
|---|---|---|
-i, --image=<path> |
ZETA_POPP_KARTOS_IMAGE |
— |
--kartos-bin=<path> |
ZETA_KARTOS_BIN |
kartos on PATH |
--service-url=<url> |
ZETA_POPP_SERVICE_URL |
popp dev service URL |
Note on repeatable options. --ca-cert, --header, and --scope accept multiple values on the CLI (repeat the flag) and in zeta.yaml (YAML list), but their env var holds only a single value. Use the CLI flag or YAML when you need more than one.
zeta.yaml lets you persist defaults so you don't repeat flags. Keys are the long option names without leading dashes (e.g. --auth-method → auth-method).
--no-config / ZETA_NO_CONFIG |
-f / ZETA_CONFIG |
Loaded |
|---|---|---|
| no | no | ./zeta.yaml if present, else $XDG_CONFIG_HOME/telematik/zeta/zeta.yaml if present, else none |
| no | yes | the named file (must exist; missing → usage error) |
| yes | no | none — built-in defaults only |
| yes | yes | error: mutually exclusive |
First hit wins — files are never merged. When -f is passed (or ZETA_CONFIG is set) the file must exist; a missing path fails fast with a usage error rather than silently falling through. Without -f, auto-discovery applies and is happy to find no file at all.
-f is the docker-compose idiom for switching between scenarios: keep one zeta.yaml per environment, point at one with the flag.
# ~/.config/telematik/zeta/zeta-popp-ru.yaml
profile: popp-ru # SDK state lands in popp-ru.storage.json
auth-method: connector
auth-connector-telematik-id: "${SMCB_TID_RU}"
scope: [popp]zeta -f ~/.config/telematik/zeta/zeta-popp-ru.yaml http https://popp.dev.poppservice.de/some/api
# Or pin per-shell:
export ZETA_CONFIG=~/.config/telematik/zeta/zeta-popp-ru.yaml
zeta http https://popp.dev.poppservice.de/some/api
zeta popp connectorPair each scenario file with its own profile: so SDK state (registrations, tokens) is also isolated — popp-ru writes to popp-ru.storage.json, popp-tu writes to popp-tu.storage.json, no cross-contamination.
Single file only — there is no docker-compose-style multi--f merging today.
For a totally clean run with no config at all, see
--no-configbelow.
Use --no-config (or ZETA_NO_CONFIG=1) when you need built-in defaults only — no project-local ./zeta.yaml, no XDG fallback, no -f. Useful for:
- Reproducing an issue without your
zeta.yamlinterfering. - One-off ad-hoc commands from an arbitrary directory.
- CI runs where output must be predictable regardless of the surrounding environment.
zeta --no-config status # ignores any zeta.yaml on disk
ZETA_NO_CONFIG=1 zeta status # env-var equivalent--no-config is mutually exclusive with -f / ZETA_CONFIG. Passing both is treated as a mistake — the CLI exits with Error: --no-config is mutually exclusive with -f / ZETA_CONFIG rather than silently picking one. Only 1, true, or yes (case-insensitive) activate the env var; 0/empty leave it off.
- Top-level keys apply to any command that defines that option.
- Subcommand-scoped keys (
<subcommand>: { … }) only apply when that subcommand runs. Scoped wins over top-level. ${VAR}placeholders are expanded against the process environment before parse — secrets and per-host overrides stay out of the file.- Repeatable options take a YAML list.
The shape is the same as a TOML file: flat key–value pairs at the top, named sections for scope. Two rules:
- Top-level kebab keys are flag names verbatim.
auth-method↔--auth-method. The hyphen is part of the option name, not a path separator. - Nested mappings are subcommand scope, not name hierarchy.
http: { header: [...] }means "the--headerflag when invoked withzeta http". Nesting depth mirrors the command path:popp.connector.connectionis the--connectionflag underzeta popp connector.
So auth-method and popp.connector.connection look different on the page because they are different things: a flag name vs. a command-scoped flag. The two never mix — a key never means "namespaced option".
# Defaults for every command
profile: dev
connector-config: my-kon
auth-method: connector
auth-connector-telematik-id: "${SMCB_TID}"
# Per-subcommand overrides
http:
header:
- "X-Trace-Id: ${USER}-dev"
include: true
ws:
header:
- "X-WS-Trace: ${USER}-dev"
popp:
connector:
connection: contactlessWith this in ./zeta.yaml, the example from Quick start shrinks to:
zeta login https://popp.dev.poppservice.de --scope popp
zeta http https://popp.dev.poppservice.de/some/api --scope popp
zeta popp connectorCLI flag > env var > zeta.yaml value > built-in default. So a YAML value never overrides what you typed at the prompt, but it does override what the binary ships with as default.
A sticky option (e.g. --connector-config) declared at a parent depth (zeta --connector-config=X ...) gets re-read by the child from the same YAML key and may be overwritten. Workaround: pass sticky overrides at the deepest depth (zeta connector inspect --connector-config=X).
- Logs go to stderr; data goes to stdout — safe for shell pipelines.
- Default log level is
warn. Bump with-v(info),-vv(debug),-vvv(trace). -o jsonemits parseable JSON without colour when stdout is piped.-o raw(forhttp) prints the body verbatim with no framing.- Colour follows TTY detection and respects
NO_COLOR/FORCE_COLOR.
The CLI is built against a single zeta-sdk version (1.2.0), pinned in
gradle/libs.versions.toml. zeta version prints it:
$ zeta version
zeta-cli 0.8.2
zeta-sdk 1.2.0Override the pin at build time with -PzetaSdkVersion=<tag> (e.g. latest from mavenLocal).
Requires JDK 21. The Gradle wrapper auto-provisions one if needed.
Run straight from sources via :cli:run — the ./zeta-dev wrapper forwards args verbatim:
./zeta-dev version
./zeta-dev discover https://popp.dev.poppservice.de./gradlew :cli:installDist
./cli/build/install/zeta/bin/zeta version
export PATH="$PWD/cli/build/install/zeta/bin:$PATH" # optionaljust install # builds tarball, materialises a private tap, brew install
zeta versionInstalls into $(brew --prefix)/bin and pins JAVA_HOME to Homebrew's openjdk@21.
Common tasks are wired up in the justfile:
just install # build and brew-install locally from Formula/zeta.rb
just demo # render demo.gif from demo.tape (requires vhs)By default the CLI builds against the version of de.gematik.zeta:zeta-sdk-jvm pinned in gradle/libs.versions.toml (currently 1.2.0, resolved from Maven Central). To swap in a local SDK build during development without editing the catalog, override the Gradle property at the command line:
# Use whatever you publishToMavenLocal'd as `latest`
./gradlew :cli:installDist -PzetaSdkVersion=latest
# Or any other tag living in your ~/.m2/repository/de/gematik/zeta/zeta-sdk-jvm/
./gradlew :cli:installDist -PzetaSdkVersion=1.0.2-localSet it persistently in ~/.gradle/gradle.properties (zetaSdkVersion=latest) to avoid passing the flag every time — Gradle will still let project- or command-line values override that.
zeta version prints the resolved SDK version so you always know which one shipped in the binary.
The release tarball (./gradlew :distTar) ships two entry points from one archive — bin/zeta (the CLI) and bin/zeta-stress (the load-test tool) — over a shared lib/.
