Badge
Data-to-image rendering: register versioned SVG templates with typed
placeholders, bind them to live state, and serve cacheable image/svg+xml
badges - over the authenticated API or anonymously at a public URL - with
optional Ed25519-signed metadata proving what the badge said and when.
When to reach for it: build and service-status badges for READMEs and dashboards, leaderboard rank badges on player profiles, social-share proof images, agent-verifiable state receipts read from signed metadata.
When not to: tracking whether a user earned something belongs to Achievements - Badge renders images from state, it does not track progress. Hosting static images belongs to Blob.
Concepts
- Templates are SVG documents with handlebars-style
{{slots}}, created as version 1 and extended by appending versions. SVG is sanitized on write -<script,javascript:, event handlers are400 invalid_svg. - Placeholders are typed -
string,number,enum,color- with optional defaults and arequiredflag. Only declared placeholders are substituted; an undeclared{{slot}}passes through as literal text. - Thresholds are ordered rules over the resolved state; the first
matching
when(e.g.$.state == 'passing') sets display variables like colors, so callers pass data and the template picks looks. - State sources are registered lookups (KV, leaderboard, remote config, literal) that feed placeholders server-side with live state.
- Lifecycle per version:
draft->active->deprecated/disabled. Renders resolve the active version unless pinned; disable is the only state that rejects renders, and it is reversible. - Access starts private to the creator in the organization’s hidden root
workspace. Optional
workspace_idandaccess_visibility: "workspace"use an existing workspace’s grants. The badge’s versions share one permission root; readers can inspect/render, editors can append versions and change lifecycle, and managers can share it. - Public rendering is separate:
visibility: "public"permits anonymous SVG output, without making source definitions or administration public.
Create a template
snug badge template-add --file status-badge.json --activate takes a
JSON CreateTemplateRequest; --activate skips the draft stage:
{
"name": "docs4.build-status",
"svg_template": "<svg xmlns=\"...\" width=\"120\" height=\"20\">...fill=\"{{color}}\"...{{label}}...{{message}}...</svg>",
"placeholders": [
{"name": "label", "type": "string", "default": "build"},
{"name": "state", "type": "enum", "values": ["passing", "failing"], "required": true},
{"name": "message", "type": "string", "default": "unknown"},
{"name": "color", "type": "color", "default": "#9f9f9f"}
],
"thresholds": [
{"when": "$.state == 'passing'", "set": {"color": "#2da44e", "message": "passing"}},
{"when": "$.state == 'failing'", "set": {"color": "#d73a49", "message": "failing"}}
],
"cache": {"ttl_seconds": 30},
"verification": {"signed_metadata": true}
}Without --activate the template stays draft; the owner can still
render it (with "preview": true to skip cache writes) before activating.
Render
The CLI renders by id: snug badge render --id XHSb... --param state=passing --output badge.svg. Over HTTP,
GET /api/v1/badges/render/{template_id}.svg returns the raw image - the
.svg suffix is optional, and every query parameter except version
becomes a render parameter:
curl -H "Authorization: Bearer $SNUG_API_TOKEN" \
"$SNUG_API_URL/api/v1/badges/render/0199e100-0000-7000-8000-000000000008.svg?state=failing"
# content-type: image/svg+xml
# cache-control: private, no-storePOST /api/v1/badges/render returns JSON with the SVG plus cache detail
and the signed metadata (captured live, trimmed):
{
"status": 200,
"msg": "OK",
"data": {
"render_id": "0199e100-0000-7000-8000-bd935d3c64f4",
"template_id": "0199e100-0000-7000-8000-000000000008",
"template_version": 1,
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>",
"state_hash": "sha256:ca0aeea44cb44f96...",
"cache": { "hit": true, "ttl_seconds": 30 },
"verification": { "signature_alg": "ed25519", "signature": "Vqc99EBUeL7...", "...": "..." }
}
}Validation is strict: a missing required placeholder is
400 missing_placeholder, a value outside an enum's list is 400 invalid_placeholder, and an unknown id is 404 template_not_found.
Versions and lifecycle
snug badge template-version XHSb... --file status-v2.json --activate
snug badge render --id XHSb... --version 1 --param state=passing # pin the old version
snug badge template-deprecate XHSb... --version 1
snug badge template-disable XHSb... --version 1
snug badge template-activate XHSb... --version 1 # reverses disableDeprecated versions still render when pinned; a disabled version fails
with 403 template_disabled while the active version keeps working. Note
template-list returns one row per version (a two-version template
appears twice); filter with --filter 'status[eq]=active' for current versions.
Public badges
Templates start private. Flipping one public enables the anonymous
route, which lives at the server root, not under /api/v1:
snug badge template-visibility XHSb... --visibility public
curl "$SNUG_API_URL/badges/public/render/0199e100-0000-7000-8000-000000000008.svg?state=passing"While the template is private the same URL is 404 template_not_found -
existence is not leaked. Authenticated reads and lists remain permission scoped.
A public SVG does not grant access to the template source, its metrics, or lifecycle.
Platform administrator claims do not bypass private resource grants.
To share source access with another organization member:
snug access grant --target badge-templates --id "$TEMPLATE_ID" --subject-kind user --subject-id "$USER_ID" --role readerUse the existing Access CLI’s subject options for groups and service accounts.
Grant editor for lifecycle/version management or manager for sharing authority.
The same grants apply to all versions; revocation is checked before cached output.
State sources
A state source resolves a placeholder server-side. kind is literal,
kv, leaderboard, remote_config, or composite; the key_template
is rendered from the request parameters and addresses the other service's
backing storage key verbatim (tenant prefix included), so wiring one up is
an operator-level task. Raw KV/leaderboard/remote-config references require a
current tenant administrator and a t:<current-tenant>: key prefix. Literal and
composite sources do not grant raw Redis access. Source records are private unless
shared through badge-sources or access_visibility: "workspace".
Every template binding requires source read permission. For anonymous rendering,
the source must additionally opt in with "public_render": true; otherwise
publication is rejected. Sources are checked again before parameter overrides or
render-cache lookup. This is explicit approval to publish the source’s resolved
output, not access to its configuration.
For example:
snug kv set docs4/service-health '"passing"'
snug badge source-add --file source.json # {"kind": "kv", "key_template": "t:TDxB...:kv:j:docs4/service-health", "fallback": {"value": "unknown"}}
snug badge source-listBinding {"placeholder": "state", "source_id": "0199e100-0000-7000-8000-000000000009"} in a template's
state_sources lets renders work with no parameters at all: the badge flipped from green to red after
snug kv set docs4/service-health '"failing"' once the cache TTL lapsed.
Two verified gotchas: a caller-supplied parameter overrides the bound
source for that placeholder, and fallbacks go through normal placeholder
validation - a fallback outside an enum's values is 400 invalid_placeholder when the source is missing.
Cache and metrics
Rendered output is cached by template, version, resolved state, and
metadata mode; cache.ttl_seconds drives the internal cache and public SVG cache headers.
Authenticated SVGs use Cache-Control: private, no-store so shared HTTP caches
cannot leak a private render. Purge after out-of-band changes:
snug badge cache-purge XHSb... # {"purged_entries": 2, ...}
snug badge template-metrics XHSb... # render_count, cache_hit_ratio, p50/p95 latency, render_errors by class, verification_failuresStorage and tenant boundaries
PostgreSQL owns templates, versions, state-source definitions, active-version selection and quota enforcement. Creating a version and activating it commit atomically. Redis holds disposable render caches, metrics and the live values referenced by configured raw sources. Losing the render cache does not lose the badge or its public URL. New IDs use UUIDv7.
Omitting workspace fields requires no organization setup beyond normal sign-in.
For a named workspace, add "workspace_id": "..." and optionally
"access_visibility": "workspace" to the JSON file passed to template-add or
source-add. Anonymous publication remains controlled by the separate visibility.
Phrase, prefix, contains and one-edit fuzzy name filters remain available through
the list API. PostgreSQL migrations install the declared fuzzystrmatch extension
for fuzzy matching; filter values are bound parameters and remain permission scoped.
Phrase search uses English text-search normalization; fuzzy search matches words
within one edit.
Limits and configuration
Templates up to 128 KB, rendered output up to 256 KB, 50 placeholders and
20 state sources per template, cache TTL capped at 300 seconds (default
60) - all tunable via the BADGE_* variables.
Reference
- Badge API - every endpoint, callable
- Signed badges and verification - Ed25519 receipts
- Related: Achievements for earning logic, KV Store and Leaderboard as state sources
Identifiers
Template and state-source IDs are opaque UUIDv7 strings. Template versions remain integers, and placeholder names remain user-defined keys.
