Labels¶
Labels are kasas's built-in categorization: strict key: value pairs attached
to a transaction. They are the metadata you own — a sync refreshes everything
the source reports but never touches your labels.
Source: internal/labels.
The model¶
A transaction's labels are a JSON object of key → value, both non-empty strings,
stored in the transactions.labels column:
A "simple tag" is just a label with a conventional key — e.g. tag: groceries.
There is no separate tag type; one key:value model covers both.
Normalization¶
Every write runs through labels.Normalize, which makes labels predictable and
collision-free:
- Keys are lowercased, trimmed, stripped of quotes / backslashes / control
characters, and capped at 50 runes — so
Category,category, and"category"all converge tocategory. - Values are trimmed and capped at 50 runes; their case is preserved.
- Pairs with an empty key or value are dropped (the "strict" in strict
key:value). - At most 50 labels per transaction; keys are processed in sorted order for deterministic output.
Filtering¶
Label keys are canonical (lowercase), so filtering by key is case-insensitive; value matching in the REST drill-down is exact (case-sensitive). The filter is pushed down to JSON SQL in both the SQLite and Postgres backends, so a consumer can build its own views without scanning every row:
# every transaction carrying the key, any value
curl "localhost:8080/api/v1/transactions?label_key=category"
# scoped to one exact value
curl "localhost:8080/api/v1/transactions?label_key=category&label_value=food"
For richer matching — value contains, not-equal, presence, combined with other
fields and booleans — use the search language's label: field, which
matches case-insensitively:
label:category=food label value equals (case-insensitive)
category:food shorthand for the above
label:category key present, any value
label:store~whole value contains
label:category!=food value not equal
Editing labels¶
| Surface | How |
|---|---|
| Dashboard | Click a transaction's Labels cell and type category: food; typeahead suggests existing labels. |
| REST | PUT /api/v1/transactions/{id}/labels with {"labels":{"category":"food"}} — replaces the whole set. |
| Rules | A rule applies labels automatically on match. |
| Plugins | A plugin with the labels:write capability calls kasas.apply_labels. |
Every one of these routes through the same emitter seam, so a
label change — wherever it originates — emits granular label.applied /
label.removed events (one per changed key) and appends a labeled
history snapshot. The diff between old and new labels is
computed once, centrally, and reused by all writers.
The label vocabulary¶
# every label in use, with how many transactions carry it
curl "localhost:8080/api/v1/labels"
# -> [{"key":"category","value":"food","transaction_count":42}, …]
# remove a key from every transaction (add ?value= to scope to one value)
curl -X DELETE "localhost:8080/api/v1/labels/category"
A bulk vocabulary delete emits a single coarse label.removed event with
entity_type: "label" (versus the granular per-transaction events of a single
edit). The vocabulary is also available via the list_labels MCP tool and the
dashboard Labels page.
Labels vs. extensions¶
Labels are for human categorization — strict, lowercase-keyed strings you filter and group by. When an application needs to attach richer, typed, or namespaced data, that's what schema extensions are for. They're parallel models, not competitors:
| Labels | Extensions | |
|---|---|---|
| Value type | strings only | any JSON (string, number, bool, null, object, array) |
| Keys | lowercased, canonical | namespaced, case-preserved |
| Audience | you (categorization) | apps (integration data) |
| Set by | UI, REST, rules, plugins | REST, MCP, plugins (UI shows read-only) |