ServicesKV Store

KV Store

A general-purpose JSON key-value store: hierarchical keys with TTL expiration, tagging, per-user privacy, full-text search, batch operations, atomic counters, RFC 7396 merge patching, and binary values.

When to reach for it: user preferences, feature flags, cached computations with TTL, view/like counters, tagged content metadata, scratch state for agent sessions.

When not to: distributed locks and compare-and-swap belong to Distributed State; schema-validated, environment-scoped configuration with inheritance belongs to Remote Config.

Concepts

  • Key paths are hierarchical - slash-separated, like config/db/host. A shared prefix makes related keys listable and countable together.
  • Values are JSON - any JSON value, including a bare (quoted) string or number. Raw bytes go through the separate binary endpoints instead.
  • Tags are labels orthogonal to paths, for when one key belongs to several categories.
  • Private keys are visible only to their creator: they vanish from other users' lists and searches, and a direct read by someone else fails with 403 access_denied rather than a silent miss.
  • Indexing is opt-in and not retroactive - only values written with indexed participate in full-text search. To make an existing key searchable, re-set it.
  • Conditional writes - nx (only if absent) and xx (only if present) avoid get-then-set races. They are mutually exclusive; combining them fails with 400 nx and xx are mutually exclusive.

Store and read

snug kv set config/db/host '"localhost"' --ttl 3600
snug kv set user/prefs '{"theme":"dark"}' --tags ui,user --private

snug --output json kv get user/prefs
snug kv get user/prefs --json-path '$.theme'   # partial read
snug kv head config/db/host                    # cheap existence check, exits non-zero if missing
snug kv delete config/db/host --force

Over HTTP the same operations are PUT/GET/HEAD/DELETE on /api/v1/kv/{path}:

curl -X PUT -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"value": {"greeting": "hello world"}}' \
  http://localhost:4000/api/v1/kv/docs/hello
{
  "status": 201,
  "msg": "Created",
  "data": {
    "key": "docs/hello",
    "owner": "docs-writer",
    "private": false,
    "content_type": "application/json",
    "size_bytes": 26,
    "tags": [],
    "indexed": false,
    "created_at": "2026-08-28T03:41:02.485104Z",
    "updated_at": "2026-08-28T03:41:02.485104Z"
  }
}

The value must be valid JSON - the CLI parses it locally before sending, so a bare hello is rejected client-side; write '"hello"'.

Counters

incr atomically adjusts a numeric value. It does not create missing keys - unlike Redis INCR, a missing key is 404 key_not_found, and a non-numeric value is 400 type_mismatch. Initialize first:

snug kv set stats/page-views '0' --nx
snug kv incr stats/page-views
snug kv incr stats/errors --delta -1   # decrement

Partial updates

patch applies an RFC 7396 merge patch - a deep merge, so sibling fields survive. Verified:

snug kv set docs/settings '{"nested":{"x":1,"y":2}}'
snug kv patch docs/settings '{"nested":{"z":3}}'
snug --output json kv get docs/settings
{ "value": { "nested": { "x": 1, "y": 2, "z": 3 } } }

To delete a field, set it to JSON null in the patch. To replace a nested object outright, use set.

TTL

snug kv set cache/session/abc '{"user":"alice"}' --ttl 1800
snug kv ttl cache/session/abc --sliding       # auto-extend on every read
snug kv ttl cache/session/abc --extend 3600   # reset to N seconds from now
snug kv ttl cache/session/abc --remove        # make permanent

Batch operations

snug kv batch-get config/a,config/b,config/c
snug kv batch-set --file items.json
snug kv batch-delete config/old/a,config/old/b --force

batch-set --file takes a bare JSON array; each item supports key, value, ttl_seconds (not ttl), tags, indexed, private:

[
  { "key": "config/a", "value": "hello", "ttl_seconds": 3600 },
  { "key": "config/b", "value": {"nested": true}, "tags": ["ui"], "indexed": true }
]

Batch size is capped (100 keys by default; see the configuration reference).

Binary values

Raw bytes - images, PDFs - go through dedicated endpoints that stream application/octet-stream:

snug kv set-binary images/logo.png --file ./logo.png --ttl 86400 --private
snug kv get-binary images/logo.png --out ./logo-copy.png

A binary key lists and deletes like any other. incr and patch reject it with 400 type_mismatch, and a JSON get of a binary key is 404 key_not_found - use get-binary. For large files with signed download URLs, use the Blob service instead.

Finding keys

Listing, tag and owner filters, full-text search over indexed values, and aggregate stats have their own page: Search and discovery.

Limits and configuration

Values up to 4 MB, key paths up to 512 characters, and 20 tags per key by default - all tunable, along with default TTL and the expiration worker, via the KV_* variables in the KV CONFIG reference.

Reference

On this page