CLI

beta

doorman — a command-line client for the admin API: contexts, putting a site behind the gate, handing out PINs and share links, and a doctor that checks the whole path.

CLI

Doorman ships no admin UI (on purpose), which for a long time meant the only way to add a site was curl. doorman is a thin client over the same admin API — no new concepts, one less place to get a JSON body wrong.

Install

curl -fsSL https://agentics.dk/install/doorman-cli.sh | bash

Windows PowerShell:

irm https://agentics.dk/install/doorman-cli.ps1 | iex

The release component is doorman-cli; the installed command is doorman. It is versioned with the server it talks to — doorman version and the instance's /healthz report the same semver, because one release cuts both.

Point it at an instance

A context is a base URL and an admin token, stored in ~/.config/doorman/config.json with mode 0600:

doorman context add arvo --url https://kunde.arvo.works --token "$DOORMAN_ADMIN_TOKEN"
doorman context list
doorman context use arvo

The base URL is any hostname the instance serves — the admin API answers on all of them, under the reserved prefix. The CLI adds /_doorman/api/v1 itself.

Three sources, highest first:

SourceWins overUse it for
--url / --token flagseverythingone-off calls against another instance
DOORMAN_URL / DOORMAN_ADMIN_TOKENthe config fileCI, where nothing should be on disk
the active contextdaily work

401 prints which of the three the credential came from. A token pasted into a shell six weeks ago and an env var exported by a different tool fail identically otherwise.

Put a site behind the gate

The four calls a platform needs, as four commands:

# 1. the site — upstream is the docker network alias, never a hostname
doorman site add griller \
  --host griller.arvo.works \
  --upstream griller:3000 \
  --display-name "Griller.dk" \
  --pin --banner="Forhåndsvisning for Griller.dk" \
  --session-hours 168 --accent '#f87f2e' --locale da

# 2. a way in — a PIN to read aloud, or a link to paste into an email
doorman pin create griller --label "Anna hos Griller" --expires 2026-09-01
doorman link create griller --label "Godkendelse af forsiden" --single-use

# 3. who has been in
doorman audit griller --limit 50

# 4. take it down
doorman site rm griller

Once a site has requestLog.enabled, doorman requests is the other half of that question — everyone who reached the site, not only everyone who got in:

doorman site set griller requestLog.enabled=true requestLog.retentionDays=30
doorman requests griller --limit 100
doorman requests griller --find 203.0.113.9 --from 2026-08-01 --to 2026-08-12

doorman stats is the third question — not who was let in and not who was here, but how the site is doing. It needs no request log at all:

doorman site set griller analytics.enabled=true
doorman stats griller --days 30
doorman stats griller --from 2026-08-01 --to 2026-08-12 --top 40

# returning visitors, once the site asks for devices and a visitor consents
doorman site set griller analytics.identity=device
doorman stats griller --cohorts --weeks 12

The note: lines under the report come from the instance and are printed verbatim — they say which figures the collection method cannot support. See analytics and consent.

A share link's url is shown once: doorman stores the record, not the token. Nothing recovers it — issue a new link and revoke the old one.

site rm deletes the site directory, PINs, links and the audit log. To stop serving while keeping the history, doorman site set griller enabled=false or give it an expiresAt.

Editing an existing site

PUT /sites/{slug} is create-or-replace, not a patch. site set therefore reads the record, changes the keys you named, and writes the whole thing back:

doorman site set griller access.magicLinkEnabled=true theme.title="Griller.dk"

Do not hand-roll this with curl unless you send the full record. A partial body resets every field it omits — the classic version is a one-flag edit that quietly turns the site's PIN off.

Pointing a site at the customer's own login

doorman site set kunde \
  sso.enabled=true \
  sso.issuer=https://id.agentics.dk/realms/agentics \
  sso.clientId=doorman-kunde \
  sso.label="Log ind med Agentics" \
  sso.allowedDomains=@kunde.dk

Register https://<host>/_doorman/signin-sso as the redirect URI at the provider, and prefer a public client with PKCE — then there is no secret.

sso.clientSecret is write-only: it never comes back from a read, so site set cannot preserve a value it was never shown. Naming it with an empty value — sso.clientSecret= — therefore means clear the stored secret, and any other edit leaves it alone.

--json is available on every read command (site list, site get, pin list, link list, ticket list, audit), so the same calls work in a script. Without it you get a plain table, the same in a terminal and in CI logs.

doctor

doorman doctor <slug> walks the path a visitor takes and checks each hop — the same list self-hosting asks you to verify by hand after adding a customer:

$ doorman doctor griller
site        griller — enabled, expires 2026-10-01                    ok
hosts       griller.arvo.works — served by this instance             ok
upstream    griller:3000 — alias resolves, answered 200              ok
access      pin — 2 active PINs, 1 share link                        ok
crawlers    X-Robots-Tag: noindex, nofollow, noarchive               ok
gate        unauthenticated navigation → 302 /_doorman/gate          ok
unknown     an unknown Host got 404                                  ok
magic link  access.magicLinkEnabled, but SMTP is not configured    FAIL

1 problem. Magic links are only offered when SMTP is configured on the instance;
this site's gate will show the PIN form only.

A site with sso.enabled gets one more row. Half-configured customer SSO is the failure that leaves no trace — the gate hides a button it cannot build a challenge for — so doctor fails on a missing issuer or clientId, warns when neither requiredRole nor allowedDomains narrows who gets in, and otherwise prints the redirect URI to register at the provider.

Exit codes: 0 all checks passed, 1 you used it wrong, 2 the API said no, 3 doctor found a problem. A 503 from the upstream check is the interesting one — it means the alias does not resolve or nothing is listening, which is the one thing about deploying behind doorman that goes wrong more than once.

Without installing anything

The API is the product surface; the CLI is a convenience. Every command above has a curl equivalent in the admin API reference, and a platform integrating against doorman should call the API directly rather than shelling out to this.