Server
stylist serve runs the core API: centralized rulesets, a verdict for
every POSTed asset, and a record of every check. That record is the
dashboard’s data feed. The same command backs hosted and self-hosted
deployment; nothing in it assumes either.
stylist serve -rulesets ./rulesets \ -addr :8420 \ -db stylist.db \ -log checks.jsonl \ -reload 30sEndpoints
Section titled “Endpoints”POST /v1/check
Section titled “POST /v1/check”{ "ruleset_id": "acme-brand-v1", "asset": "docs/page.html", "content": "<html>…</html>", "source": { "adapter": "git-hook", "repo": "marketing-site" }}Returns the verdict (200), 404 for an unknown ruleset, 400 for a bad
request. asset is a reporting label; the server never opens paths.
source attributes the check in the log/store and is not echoed into
the verdict.
Binary assets (images) are sent as content_base64 instead of
content: raw image bytes are not valid JSON string data. The adapters
switch automatically; hand-rolled clients should base64-encode anything
that isn’t valid UTF-8.
POST /v1/rulesets/validate
Section titled “POST /v1/rulesets/validate”{ "content": "id: acme-brand-v1\ncolors:\n allowed: [\"#1A2B3C\"]\n" }Checks whether content (raw ruleset YAML) parses against the real
schema. Always 200, with the answer in the body:
{ "valid": true, "errors": [] }A ruleset that fails to validate is still a 200 — the request itself
succeeded, the ruleset just isn’t valid. Only a malformed request body
(not valid JSON) is a 400. Deliberately unauthenticated, like
/healthz: it’s stateless and touches no store data, so there’s nothing
a credential would protect. The control plane calls
this when an admin publishes a ruleset.
GET /healthz
Section titled “GET /healthz”Always 200, always unauthenticated — for load balancers and container probes.
GET /dash
Section titled “GET /dash”The dashboard, when a check store is configured.
Authentication
Section titled “Authentication”Set a bearer token to require Authorization: Bearer <token> on the
API (constant-time compared, /healthz exempt):
STYLIST_API_TOKEN=… stylist serve … # or -tokenClients pick the token up from STYLIST_API_TOKEN automatically. An
empty token disables auth (localhost/dev).
Personal login tokens
Section titled “Personal login tokens”Alongside the shared token above, the server can also accept a personal
token from someone who ran stylist login. See Personal
attribution. This needs
-oidc-cli-client-id (default $STYLIST_OIDC_CLI_CLIENT_ID), a
separate, public OIDC client registration from the one dashboard SSO
uses. Without it, a personal token on /v1/check is simply not
accepted and the request falls back to whatever token auth is
otherwise configured.
Multi-org mode
Section titled “Multi-org mode”-multi-org accepts opaque per-org API tokens instead of the single
shared -token, for hosted/multi-tenant deployments where different
customers must never see each other’s data:
stylist serve -rulesets ./rulesets -db 'postgres://…' -multi-orgEach token is looked up (by SHA-256 hash) against the store’s
api_tokens table, which resolves it to an org. Every check made with
that token is recorded under that org. For tables covered by row-level
security (see Database), it’s also
isolated from every other org’s data at the database level, not just by
application-level filtering.
Requirements, checked at startup (the server refuses to start rather than run with isolation silently not enforced):
-dbmust be apostgres://DSN: row-level security, and so real multi-org isolation, is Postgres-only. SQLite is rejected.- The database role
-dbconnects as must not be a Postgres superuser orBYPASSRLSrole. Either unconditionally bypasses row-level security regardless of policy, so a superuser connection would look correctly configured while enforcing no isolation at all. Use a dedicated, ordinary role for the server’s runtime connection — never the admin/superuser account you migrate with. -multi-organd-tokenare mutually exclusive. A static shared token authenticates with no org attached, which has no safe meaning once per-org tokens are in play. The server won’t start with both configured.
Orgs and API tokens are created and revoked through the control
plane, a separate admin service. See below. The dashboard is
org-isolated too, but only when -oidc-org-claim is set. See
Sign-in and
Database for what that requires.
Without it, a signed-in dashboard user still sees every org’s data, the
same as before multi-org mode existed.
Control plane
Section titled “Control plane”Org creation, API token issuance/revocation, and ruleset publishing are
handled by a separate service, controlplane, not by stylist serve itself. It’s a genuinely different binary (its own go.mod,
joined to this repo only by a go.work file) that shares the same
Postgres schema. It never mints tokens or publishes rulesets through
stylist serve, and stylist serve never creates orgs. This keeps the
two independently deployable: the control plane is an internal admin
tool (create an org, mint or revoke a token, publish a ruleset), not
something a customer ever talks to directly.
controlplane -db 'postgres://…' \ -base-url https://admin.stylist.internal \ -oidc-issuer https://your-idp.example \ -oidc-client-id stylist-controlplane-admin \ -admin-emails alice@yourco.com,bob@yourco.com \ -stylist-api-url https://api.stylist.internalSTYLIST_CP_OIDC_CLIENT_SECRET=… # env-onlySTYLIST_CP_SESSION_SECRET=… # env-only, 32+ bytes, signs admin sessionsSign-in is OIDC only, gated by -admin-emails (an allowlist of who is
permitted in, since this UI can mint tokens for any org). There is no
self-serve signup here: an admin creates an org, mints an API token for
it, and publishes a ruleset for it (paste the same YAML a self-host
ruleset file already has — the id: field inside names it), then hands
the token to the customer. Self-serve org/user signup and billing are
deliberately out of scope for now. See documents/status.md’s v2
backlog.
-stylist-api-url is optional: when set, it points at a running
stylist serve instance, and publishing a ruleset validates it against
the real schema first (via POST /v1/rulesets/validate) rather than only
checking that it has an id: field. Left unset, or if that server is
unreachable at publish time, publishing still works with just the basic
check — schema validation is a defense-in-depth improvement, not a
requirement.
Tone judge
Section titled “Tone judge”tone: rules (see Rulesets) only actually run when the
server has an LLM judge to consult. Otherwise they stay
declared-but-skipped. Enable it with an Anthropic API key:
stylist serve -rulesets ./rulesets -anthropic-api-key sk-ant-... \ -tone-model claude-haiku-4-5-anthropic-api-key(default$STYLIST_ANTHROPIC_API_KEY) — the judge is off when this is empty, which is the default. The key never reaches a client; it’s read once at server startup.-tone-model(default$STYLIST_TONE_MODEL) — which model backs the judge. Defaults to a cheap/fast model (claude-haiku-4-5). A pass/fail classification against a short guidance prompt doesn’t need frontier-tier reasoning.-anthropic-base-url(default$STYLIST_ANTHROPIC_BASE_URL) — override the API host, e.g. to point at a self-hosted proxy. Defaults to the first-party Anthropic API.
The judge is a straight text-in/text-out call (no tool use, no
structured-output API) so alternative Provider implementations
(self-hosted deployments on Bedrock, for instance) can slot in behind
the same interface without changing anything else.
Ruleset hot-reload
Section titled “Ruleset hot-reload”The rulesets directory is re-read at most every -reload (default
30s). Rule changes propagate to every adapter without restarts. A
broken edit (invalid YAML, duplicate ID) does not take down
checking: the server keeps serving the last good registry until the
directory parses again. Startup still validates the directory so a
misconfigured server fails fast rather than serving nothing.
Check log and store
Section titled “Check log and store”Every check is written twice:
- JSONL log (
-log, default stdout): one line per check, containing the full verdict plussource. Ship it to your log pipeline, or ignore it. - Check store (
-db): SQLite file orpostgres://DSN. This is what the dashboard queries. See Database.
A store write failure never fails the check: the verdict is already correct, and losing one dashboard row beats blocking a commit.
Deployment
Section titled “Deployment”make dockerdocker run -p 8420:8420 \ -v /srv/stylist/rulesets:/rulesets:ro \ -e STYLIST_API_TOKEN=… -e STYLIST_LICENSE=… \ stylist:VERSIONThe image is distroless (no shell, no package manager), built from a static binary. For VPC/self-hosted customers this one container is the entire product: check API + rule registry + store + dashboard.