Kromgo

June 2, 2026 · View on GitHub

Tests E2E Lint Release License Discord

Safely expose individual Prometheus metric values to the public web. Define named endpoints backed by PromQL queries and serve them as SVG badges, themed SVG/PNG graphs, or JSON — without exposing your Prometheus instance directly.

Badges render as shields.io-style SVG, so you can embed /badges/{id} straight into an <img> tag — no shields.io round-trip required (though it's still supported via ?format=shields).

How it works

kromgo sits between the public web and your Prometheus. You define two kinds of endpoint:

  • Badges (/badges/{id}) render an instant value as an SVG badge, shields.io JSON, or kromgo JSON.
  • Graphs (/graphs/{id}) render a time series as a themed SVG/PNG chart or JSON.

Each maps a URL path to a PromQL query. Only the endpoints you define are reachable — Prometheus itself is never exposed.

The root path / serves a gallery that previews every endpoint next to its copy-paste Markdown snippet — handy for grabbing a badge for a README.

Quick start

docker run -d \
  -e PROMETHEUS_URL=http://prometheus:9090 \
  -v /path/to/config.yaml:/config/config.yaml \
  -p 8080:8080 \
  ghcr.io/home-operations/kromgo:latest

Then embed or query a badge:

<img src="http://localhost:8080/badges/node_cpu_usage" />

Docker Compose

services:
    kromgo:
        image: ghcr.io/home-operations/kromgo:latest
        environment:
            PROMETHEUS_URL: http://prometheus:9090
        volumes:
            - ./config.yaml:/config/config.yaml:ro
        ports:
            - "8080:8080"

Configuration

kromgo reads its endpoint definitions from /config/config.yaml inside the container. Mount your config file there (or pass -config /path/to/config.yaml).

Minimal example:

badges:
    - id: node_cpu_usage
      query: "round(cluster:node_cpu:ratio_rate5m * 100, 0.1)"
      valueExpr: string(result) + "%"

A JSON Schema for editor validation is published at config.schema.json; point your editor's YAML language server at it for inline completion and validation.

Environment variables

VariableRequiredDefaultDescription
PROMETHEUS_URLyesURL of your Prometheus instance
SERVER_HOSTno0.0.0.0Host to bind the main server
SERVER_PORTno8080Port for the main server
HEALTH_HOSTno0.0.0.0Host to bind the health server
HEALTH_PORTno8888Port for the health/metrics server
SERVER_LOGGINGnofalseEnable HTTP request access logging
SERVER_READ_TIMEOUTnoHTTP read timeout (e.g. 5s)
SERVER_WRITE_TIMEOUTnoHTTP write timeout (e.g. 10s)
QUERY_TIMEOUTno30sTimeout applied to each Prometheus query
LOG_LEVELnoinfoLog level: debug, info, warn, error
LOG_FORMATnojsonLog format: json or text

Defaults

defaults sets the baseline for the per-endpoint fields that support it; each endpoint overrides the same-named field. All keys are optional.

defaults:
    badge:
        font: dejavu-sans # dejavu-sans (default, shields.io-style), dejavu-sans-bold, comic-neue, comic-neue-bold
        size: 11 # badge font size in points
        style: flat # flat (default), flat-square, or plastic
        gallery:
            hidden: false # list badges in the gallery (default); true hides them
    graph:
        maxDuration: 1h # cap on a graph's requested window ("0" = unlimited)
        width: 600 # image width in px
        height: 200 # image height in px
        legend: true # show the series legend
        theme: light # color theme — see Themes below
        font: dejavu-sans # text font — see Themes below
        gallery:
            hidden: false # list graphs in the gallery (default); true hides them

The gallery page itself is toggled separately at the top level — see Gallery.

Badges

Each entry under badges: defines an instant-value endpoint at /badges/{id}.

FieldRequiredDescription
idyesURL path segment — cpuGET /badges/cpu
queryyesPromQL expression returning a single scalar or vector value
titlenoDisplay label on the badge (defaults to id)
typenoinstant (default) or range — see Range badges
rangeno*Range-query window when type: range
valueExprnoCEL expression for the displayed string — see Value and color
colorExprnoCEL expression for the color — see Value and color
labelColornoLeft-segment (label) color — a name or hex; a fixed value, not a CEL expression
stylenoflat (default), flat-square, or plastic
iconnoAn icon on the SVG badge, e.g. mdi:server-outline or si:kubernetes — see below
gallerynoPer-badge gallery settings, e.g. gallery: {hidden: true} — see Gallery

Icons

icon renders an icon on the left of the SVG badge, written as <set>:<name> for one of two sets:

It is SVG-only — the shields and json formats have no icon field and ignore it. The icon sits to the left of the title, drawn to contrast with the label background (white on the default grey, dark on a light labelColor); with an icon and no title, the badge shows just the icon and the value (the id fallback is suppressed).

badges:
    - id: nodes
      query: count(kube_node_info)
      icon: mdi:server-outline
      title: Nodes
    - id: version
      query: kubernetes_build_info
      icon: si:kubernetes
      title: Kubernetes

Both entire sets are embedded in the binary — no network or disk access at runtime — so any mdi:<name> from the MDI library (~7,400 glyphs, e.g. mdi:database-outline, mdi:rocket-launch) or any si:<slug> from Simple Icons (~3,400 logos, e.g. si:docker, si:grafana, si:prometheus) works. The sets are stored compressed (~0.8 MB MDI, ~1.9 MB Simple Icons) and each is decoded into memory only on first use. An unknown set or name fails fast at startup. The icon data is built from the @mdi/svg and simple-icons npm packages at build time (not committed) — see Building from source.

Range badges

By default a badge's value comes from an instant query at "now". Set type: range to instead run a range query over a window and reduce it to a single value — useful for averages, peaks, or comparing against an earlier period. The window is end = now - offset, start = end - last.

badges:
    - id: cpu_prev_week_avg
      type: range
      query: "cluster:node_cpu:ratio_rate5m * 100"
      range:
          last: "7d" # window length (required)
          offset: "7d" # shift the window back; here: 14d ago .. 7d ago (default: ends now)
          step: "1h" # resolution (default: last/100, min 1m)
          reduce: avg # last (default), first, avg, min, max, sum
      valueExpr: string(result) + "%"

reduce collapses each series to one value; non-finite samples (NaN/Inf) are skipped.

Value and color

valueExpr and colorExpr are CEL expressions (the Expr suffix marks the CEL-evaluated fields; query is PromQL and labelColor is a static value). CEL is sandboxed (no environment, file, or network access) and compiled once at startup, so a malformed expression fails fast rather than per request. Each expression receives two variables:

VariableTypeDescription
resultdoubleThe sample value (for type: range, the reduced value).
labelsmap(string, string)The sample's labels, e.g. labels["instance"].
  • valueExpr must return a string — the message shown on the badge. Defaults to string(result).
  • colorExpr must return a string — a shields.io color name (green, orange, red, blue, grey, …) or a hex value like "#e05d44". Omit for no color.

Text color adapts to the background for legibility — dark text on light colors, white on dark — the same way shields.io does, so a light custom colorExpr stays readable. Every badge also carries role="img", an aria-label, and a <title> ("label: message") for screen readers and tooltips.

badges:
    # numeric value with a unit + threshold coloring
    - id: cpu
      query: "round(avg(...) * 100, 0.1)"
      valueExpr: string(result) + "%"
      colorExpr: 'result < 35 ? "green" : result < 75 ? "orange" : "red"'

    # value taken from a label, falling back if it's absent
    - id: version
      query: 'label_replace(build_info, "v", "\$1", "version", "v(.+)")'
      valueExpr: labels[?"v"].orValue("unknown")

    # guard a possibly-NaN ratio (e.g. divide-by-zero) before formatting
    - id: hit_ratio
      query: cache_hits / (cache_hits + cache_misses)
      valueExpr: 'math.isNaN(result) ? "n/a" : humanizeFloat(math.round(result * 100.0)) + "%"'

    # enum → text + color
    - id: ceph_health
      query: ceph_health_status
      valueExpr: 'result == 0.0 ? "Healthy" : result == 1.0 ? "Warning" : "Critical"'
      colorExpr: 'result == 0.0 ? "green" : result == 1.0 ? "orange" : "red"'

Besides CEL's built-ins (arithmetic, comparisons, ternary ?:, in) the environment enables:

  • the strings extension — startsWith, matches, replace, substring, upperAscii, …
  • the math extension — math.round, math.abs, math.floor/ceil, math.least/greatest (clamping), and math.isNaN/isInf/isFinite to guard non-finite values (Prometheus returns NaN for e.g. division by zero, which would otherwise render literally on the badge);
  • optional typeslabels[?"k"].orValue("default") for a label that may be absent.

On top of those, these formatting helpers are available (hand-rolled — kromgo has no external humanize dependency, so the output is exactly as below):

FunctionExampleResultNotes
humanizeBytes(result)humanizeBytes(1500000.0)1.5MBSI decimal units (powers of 1000), no space
humanizeCommas(result)humanizeCommas(157121.0)157,121comma thousands grouping
humanizeFloat(result)humanizeFloat(2.50)2.5plain decimal, trailing zeros stripped
humanizeDuration(result)humanizeDuration(9000.0)2h30mseconds → compact time span
humanizeDurationDays(result)humanizeDurationDays(5961600.0)69dseconds → whole days, no roll-up

humanizeDuration takes seconds (so it drops onto a time() - created_ts query directly) and adapts to the magnitude, emitting the up-to-three most-significant units — 901m30s, 90002h30m, 403488001y3mo12d. Months render as mo so they never collide with minutes (m) in the same string.

For coloring, colorScale(result, steps, colors) maps a number to a shields.io color name, so a colorExpr doesn't need a hand-written chain of ternaries. It returns colors[i] at the first result < steps[i], otherwise the last color — so colors has one more entry than steps. Write the thresholds as decimals (35.0, not 35); an integer literal fails to compile.

# instead of
colorExpr: 'result < 35 ? "green" : result < 75 ? "orange" : "red"'
# use
colorExpr: 'colorScale(result, [35.0, 75.0], ["green", "orange", "red"])'

For a percentage — say red below 80, green by 100 — just list the cutoffs and their colors:

colorExpr: 'colorScale(result, [80.0, 90.0, 100.0], ["red", "yellow", "green", "brightgreen"])'

Two gotchas around result (a double):

  • Numeric literals. Ordered comparisons accept plain integers — result < 35 works (kromgo enables CEL's cross-type numeric comparisons). Equality and arithmetic do not: write a decimal literal there, e.g. result == 0.0 (not == 0) and result * 100.0 (not * 100). A mismatch is a compile error caught at startup, not a runtime surprise.
  • Missing labels. Indexing a label that isn't present errors. Use optional indexing — labels[?"k"].orValue("n/a") — or the ternary "k" in labels ? labels["k"] : "n/a".

Graphs

Each entry under graphs: defines a time-series endpoint at /graphs/{id}. Defining a graph is the opt-in to expose range data for that query — there is no separate enable flag. Charts are rendered by go-analyze/charts as SVG (default) or PNG (?format=png).

FieldRequiredDescription
idyesURL path segment — cpuGET /graphs/cpu
queryyesPromQL expression run as a range query
titlenoDisplay label (defaults to id)
maxDurationnoCap on the requested window (overrides defaults.graph.maxDuration)
widthnoImage width in px (overrides defaults.graph.width)
heightnoImage height in px (overrides defaults.graph.height)
legendnoShow the series legend (overrides defaults.graph.legend)
themenoColor theme (overrides defaults.graph.theme) — see Themes
fontnoText font (overrides defaults.graph.font) — see Themes
gallerynoPer-graph gallery settings, e.g. gallery: {hidden: true} — see Gallery
graphs:
    - id: node_cpu_usage
      query: "cluster:node_cpu:ratio_rate5m * 100"
      maxDuration: "30d"
      width: 800
      theme: catppuccin-mocha

The time window is chosen by these query parameters:

ParameterDefaultDescription
lastShorthand window ending now, e.g. last=7d (supports s/m/h/d/y units)
startend − 1hWindow start — Unix timestamp or RFC3339
endnowWindow end — Unix timestamp or RFC3339
stepwindow/100Resolution between points (min 1m); supports s/m/h/d/y units

The rendering fields width, height, legend, and theme, plus the output format (svg/png), may also be overridden per request via query parameters, e.g. /graphs/node_cpu_usage?theme=dracula&format=png&width=800&last=24h. (font is config-only — it's resolved once at startup.)

Themes and fonts

theme accepts a go-analyze/charts built-in or one of kromgo's bundled palettes (an unknown value falls back to the default):

  • Built-in: light (default), dark, vivid-light, vivid-dark, grafana, ant, nature-light, nature-dark, retro, ocean, slate, gray, winter, spring, summer, fall.
  • Bundled: catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha (via the official catppuccin/go palette), dracula, monokai, night-owl.

font accepts one of:

  • dejavu-sans (the default) / dejavu-sans-bold — the free, metric-compatible stand-in for the Verdana that shields.io renders with. Vendored via npm (dejavu-fonts-ttf).
  • comic-neue / comic-neue-bold — a free Comic Sans alternative (Google Fonts, via @expo-google-fonts/comic-neue), for when a badge wants some personality.

Both faces are compiled in by cmd/genassets (kept current by Renovate). Badges and graphs default to dejavu-sans (shields.io-style — 11 px text, 20 px tall); set font: to opt into the others. Fonts are compiled into the binary — there's no reading from disk, so add a face by vendoring it (npm) and PRing it into the registry. An unknown name fails fast at startup.

GET / serves a gallery: a responsive page (up to three columns, collapsing to one on mobile) that previews every visible badge and graph and shows the copy-pasteable Markdown snippet for each — the preview is rendered from that same snippet with marked, so what you see is what a GitHub README will show. Snippet URLs are absolute, built from the request host (a reverse proxy's X-Forwarded-Proto is honored for the scheme).

The page is self-contained: its JavaScript and CSS are embedded in the binary and served from /assets/ — no external CDN — so it works air-gapped and keeps a strict script-src 'self' Content-Security-Policy. See Building from source for how the assets are vendored.

Enable / disable. The gallery is on by default. Turn it off with a top-level gallery.enabled: false, which serves a minimal landing page at / instead (the badge and graph endpoints are unaffected):

gallery:
    enabled: false

Which endpoints appear. Every endpoint is listed by default. Hide one with a per-endpoint gallery.hidden: true, or flip the default per type under defaults.badge.gallery / defaults.graph.gallery:

defaults:
    badge:
        gallery:
            hidden: true # hide badges from the gallery by default…
badges:
    - id: cpu
      query: "..."
      gallery:
          hidden: false # …but list this one

When nothing is visible the gallery shows a short hint instead.

API reference

RouteDefault responseVariants
GET /badges/{id}SVG badge (?style=…)?format=shields → shields.io JSON · ?format=json → kromgo JSON
GET /graphs/{id}SVG chart (?theme=…)?format=png → PNG image · ?format=json → time-series data
GET /HTML gallerylanding page when gallery.enabled: false
GET /assets/…Embedded gallery JS/CSS

/badges/{id} (default SVG):

<img src="http://localhost:8080/badges/node_cpu_usage" />

?format=shields — the shields.io Endpoint Badge schema:

{ "schemaVersion": 1, "label": "node_cpu_usage", "message": "17.5%", "color": "green" }

?format=json — kromgo's native JSON (rendered string plus the raw number and labels):

{
    "id": "node_cpu_usage",
    "title": "CPU",
    "value": "17.5%",
    "color": "green",
    "result": 17.5,
    "labels": {}
}

/graphs/{id}?format=json — the raw time series:

{
    "id": "node_cpu_usage",
    "title": "CPU",
    "start": 1702578219,
    "end": 1702664619,
    "step": 60,
    "series": [{ "labels": { "instance": "node-1" }, "data": [{ "t": 1702578219, "v": 17.5 }] }]
}

Ports

PortPurpose
8080Main server — badge and graph endpoints
8888Health server — /healthz, /readyz, /metrics (Prometheus)

The health server's /metrics endpoint exposes Go runtime metrics plus kromgo_requests_total{kind, id, format} — a counter of requests handled, broken down by endpoint kind (badge/graph), id, and response format.

Rate limiting

kromgo does not rate limit itself — it's meant to sit behind a reverse proxy on the public web, and proxies do this better (shared limits across replicas, per-IP buckets, burst handling, 429 responses). Configure it there. Examples for limiting / traffic to kromgo on :8080:

nginx — in the http {} block, then reference the zone in your location:

limit_req_zone $binary_remote_addr zone=kromgo:10m rate=10r/s;

server {
    location / {
        limit_req zone=kromgo burst=20 nodelay;
        proxy_pass http://kromgo:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Caddy — requires the caddy-ratelimit module (xcaddy build --with github.com/mholt/caddy-ratelimit):

kromgo.example.com {
    rate_limit {
        zone kromgo {
            key    {remote_host}
            events 10
            window 1s
        }
    }
    reverse_proxy kromgo:8080
}

Envoy — the built-in local rate limit HTTP filter (100 requests/minute per listener):

http_filters:
    - name: envoy.filters.http.local_ratelimit
      typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
          stat_prefix: kromgo_rate_limiter
          token_bucket:
              max_tokens: 100
              tokens_per_fill: 100
              fill_interval: 60s
          filter_enabled:
              default_value: { numerator: 100, denominator: HUNDRED }
          filter_enforced:
              default_value: { numerator: 100, denominator: HUNDRED }

Traefik v3 — a rateLimit middleware attached to the router (dynamic file config; the Kubernetes Middleware CRD takes the same rateLimit spec):

http:
    middlewares:
        kromgo-ratelimit:
            rateLimit:
                average: 10
                burst: 20
                period: 1s
    routers:
        kromgo:
            rule: Host(`kromgo.example.com`)
            service: kromgo
            middlewares:
                - kromgo-ratelimit

Caching

Caching has two halves. kromgo owns the half only it can know — how long a value stays fresh — and emits a Cache-Control header so the other half (a browser, CDN, or GitHub's camo image proxy) knows how long to store the response. One policy applies to every endpoint; it is configured at the top level under cache: and is enabled by default.

cache:
    enabled: true # default; false sends no-store so nothing caches the badge
    maxAge: 300 # max-age + s-maxage in seconds (default 300); ignored when disabled
  • enabled: true (default) — kromgo sends Cache-Control: public, max-age=<maxAge>, s-maxage=<maxAge> on successful responses and advertises cacheSeconds in the shields.io endpoint JSON. max-age governs browser caches; s-maxage governs shared caches (CDNs, camo) — shields.io sets both.
  • enabled: false — kromgo sends Cache-Control: no-cache, no-store, must-revalidate, max-age=0. Sending no header is not the same as disabling caching: it lets camo/CDNs apply their own aggressive default (which is why an unconfigured badge can go stale), so kromgo always sends an explicit header. To turn caching off set enabled: false — not maxAge: 0, which just falls back to the 300s default.

Errors are always sent no-store. A Cache-Control header still isn't a hard guarantee against GitHub's camo proxy (shields#221), but it's the strongest signal kromgo can send.

The other half — actually storing responses — is the edge's job, and any cache that honors Cache-Control (a CDN, Varnish, nginx proxy_cache) will then cache each endpoint for the advertised maxAge. shields.io already respects cacheSeconds, so badges served through it are cached without any proxy at all.

If you want the reverse proxy itself to cache, enable its HTTP cache and let it honor the origin headers — for example, nginx:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=kromgo:10m max_size=100m;

server {
    location / {
        proxy_cache kromgo;            # respects kromgo's Cache-Control
        add_header X-Cache-Status $upstream_cache_status;
        proxy_pass http://kromgo:8080;
    }
}

Caddy (via the cache-handler plugin), Traefik, and Envoy can cache too, but generally need a plugin or an external cache/CDN; the simplest setup is to front kromgo with a CDN and let kromgo's Cache-Control header drive it.

Security

kromgo is built to face the public web. Its posture:

  • Prometheus is never exposed. Only the endpoints you define are reachable; query parameters are parsed as durations/timestamps/enums and never interpolated into PromQL.
  • SVG output is safe. Badge text and graph labels (which can derive from attacker-influenceable metric label values) are HTML-escaped, and badge/graph/JSON responses carry Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline' and X-Content-Type-Options: nosniff, so an SVG can't execute script even when opened directly.
  • The gallery loads nothing external. Its JS/CSS are embedded and served from /assets/, so the page ships a tightened-but-still-locked-down CSP (script-src 'self', no unsafe-inline/unsafe-eval, no CDN). The Host header used to build snippet URLs is validated before use.
  • Bounded work. Each Prometheus query is bounded by QUERY_TIMEOUT (default 30s); graph windows are capped by maxDuration and image dimensions are clamped. A 10s ReadHeaderTimeout guards against Slowloris; tune SERVER_READ_TIMEOUT/SERVER_WRITE_TIMEOUT to your proxy.
  • Minimal image. A scratch image with just the static binary and a CA bundle (kromgo dials Prometheus over HTTPS) — no shell, package manager, or writable filesystem. It pins no user; set one via your Kubernetes securityContext or docker run --user. Images are cosign-signed (below).

Operational guidance:

  • Expose only the main port (8080). The health port (8888) serves /metrics and probes — keep it on the internal network.
  • Terminate TLS and rate limit at your reverse proxy (see Rate limiting).
  • Treat the config as trusted (it's operator-controlled). Fonts are compiled-in (never read from disk), and CEL expressions run sandboxed (no env/file/network access).

Image verification

Images are built and Cosign-signed (keyless) by the official docker/github-builder reusable workflow, so the signing identity is that workflow rather than this repo. Verify an image before running it:

cosign verify ghcr.io/home-operations/kromgo:<tag> \
  --certificate-identity-regexp="^https://github.com/docker/github-builder/.github/workflows/build.yml@" \
  --certificate-oidc-issuer="https://token.actions.githubusercontent.com"

The exact cosign verify command (with the pinned builder ref) is also printed in each build run's summary.

Building from source

The gallery's marked.js / github-markdown.css and the full Material Design Icons and Simple Icons sets are vendored via npm (package.json + package-lock.json) and baked into the binary with //go:embed rather than committed. cmd/genassets reads node_modules and writes the embedded files, so a build runs npm ci once (network) and the resulting binary is self-contained (nothing fetched at runtime).

mise run assets   # npm ci + go run ./cmd/genassets (re-runs only when the lockfile changes)
go build ./cmd/kromgo

mise run test / lint / test-e2e depend on assets, so they build it automatically; CI and the Docker build (a dedicated node stage) do the same. Renovate keeps marked, github-markdown-css, @mdi/svg, and simple-icons current via PRs against package.json.

Upgrading 0.11 → 0.12

0.12 splits the flat metrics: list into badges: and graphs: sections, with REST-style routes. A pre-0.12 config fails fast at startup with a pointer to this guide.

ChangeAction
metrics: split into badges: and graphs:. Instant-value endpoints go under badges:; time-series endpoints under graphs:.Move each metric to the section(s) it needs. A metric you served as both a badge and a chart becomes one entry in each (with the same id).
nameid.Rename the key on every endpoint.
Routes are namespaced. GET /{name} + ?format=GET /badges/{id} and GET /graphs/{id}.Update embed URLs and shields.io endpoint URLs.
Badge default is now the SVG image. ?format=badge → default; ?format=json (shields schema) → ?format=shields; ?format=raw removed.Embed /badges/{id} directly; point shields.io at ?format=shields. ?format=json now returns kromgo's native JSON (value + result + labels).
Graph formats. ?format=chart/graphs/{id} (SVG default); ?format=history/graphs/{id}?format=json.Switch to the /graphs/ routes.
defaults.timeseries removed. The enabled gate is gone — defining a graphs: entry is the opt-in.Drop timeseries.enabled; move maxDuration to defaults.graph.maxDuration or per-graph maxDuration.
Global badge: (font/size) → defaults.badge. Badge style is now a config field too.Move badge.font/badge.size under defaults.badge.

Release tags drop the v prefix (e.g. 0.12.0, not v0.12.0); pin image tags accordingly.

Upgrading from kashalls/kromgo

This fork began as kashalls/kromgo. Beyond the schema changes above, note: the image moved to ghcr.io/home-operations/kromgo; the badge font is no longer bundled (an embedded font is used, with defaults.badge.font to override); LOG_FORMAT=test was corrected to LOG_FORMAT=text; built-in rate limiting was removed (see Rate limiting); and a missing PROMETHEUS_URL now fails fast instead of starting degraded.

Community

Thanks to everyone in the Home Operations Discord community. This project began as kashalls/kromgo.