Deduction rules automatically create activities when data conditions are met, or enrich or retype existing ones. Define rules like "when I have a sauna tag, create a sauna activity" or "when I'm meditating and listening to Holosync, create a binaural meditation activity."
Each rule has one or more conditions that resolve to time ranges. When all conditions overlap in time (AND logic), an activity is created for the overlapping period.
Rule: "Binaural Meditation"
Conditions:
- activity type "meditation" exists
- tag "Holosync" exists
Output: activity type "binaural_meditation"
Timeline:
meditation: |------9:00--------10:00------|
Holosync: |---9:15--------10:15---|
result: |---9:15--------10:00---| <-- intersection
| Kind | Description | Example |
|---|---|---|
activity |
Matches time ranges of an activity type, optionally filtered by data fields and title | {kind: "activity", activity_type: "meditation"} |
activity_data |
Matches activities of a type whose data field matches (eq, neq, exists, not_exists) |
{kind: "activity_data", activity_type: "run", field: "route", operator: "exists"} |
screentime_category |
Matches screentime in a hierarchical category | {kind: "screentime_category", category: ["Work", "Programming"]} |
location |
Matches visits to a named location | {kind: "location", location_name: "Gym"} |
after_date |
Restricts matches to after a date | {kind: "after_date", date: "2024-06-01"} |
scrobble |
Matches Last.fm scrobbles by artist/track, each covering duration_seconds |
{kind: "scrobble", artist: ["Holosync"], duration_seconds: 210} |
media |
Matches media plays (MPRIS pushes and non-duplicate Last.fm scrobbles) — see below | {kind: "media", url_host: ["truenakedyoga.com"], min_played_secs: 600} |
An activity condition's title matches case-insensitively, per match_mode: contains (the default) or exact.
A media condition matches plays whose fields satisfy every matcher given (unset matchers match anything):
| Field | Match |
|---|---|
url_host |
Any of these hosts, or a subdomain of one (www.truenakedyoga.com matches truenakedyoga.com). Plays without a parsable URL (all Last.fm plays) never match |
title |
Per match_mode (contains, the default, or exact), case-insensitive |
artist |
Any of these, per match_mode |
player |
Any of these, exact and case-insensitive (firefox, mpv) |
min_played_secs |
played_secs at least this. Plays without played_secs (Last.fm) never pass |
min_played_ratio |
played_secs / track_secs at least this (0–1). Skipped when the track length is unknown (Last.fm, some mpv files) — combine it with min_played_secs for a threshold that always applies |
A matching play covers started_at–ended_at; a Last.fm play (no end) covers one minute.
With mode: "enrich", a rule creates nothing: it patches data onto existing activities of output_activity_type
that overlap the matched ranges. output_data keys are only filled when missing, and data._enriched_by records
the rule id.
In enrich mode, a rule with at least one media condition can copy the title of a matching play into a data field:
"output_media_field": { "field": "session_name", "strip_pattern": "\\s*—\\s*True Naked Yoga$" }- For each enriched activity, the plays matching every
mediacondition within the activity's matched span are considered, and the one with the mostplayed_secswins (ties: the earliest). Several videos in one session therefore give the longest one's title. strip_patternis a JavaScript regular expression (flagsgiu, at most 200 characters); every match is removed, then whitespace is collapsed and trimmed. An empty result writes nothing.- The field is filled when missing, and overwritten when the rule wrote it before (
_enriched_byis this rule), so re-evaluating after editing the pattern updates old values. A value set by anyone else is never overwritten. Unchanged values are not rewritten. _enriched_byholds a single rule id: the last rule that enriched the activity. If another enrich rule later touches the same activity, this rule no longer recognises the value as its own and falls back to fill-only there — it never overwrites a value it did not write. To refresh such a value, clear the field on the activity and re-evaluate.- The API rejects
output_media_fieldoutside enrich mode, without amediacondition, or with a pattern that does not compile.
See Media plays for a full example.
With mode: "retype", a rule changes the type of existing activities to output_activity_type. The rule needs
exactly one activity condition, which selects the activities to change; the other conditions only narrow it down
in time (an activity is retyped when it overlaps their intersection). For example, to turn Garmin activities recorded
with a custom "Sex" profile — which Garmin reports as its base sport, other (stored as other_workout), titled
"Sex" or "<place> Sex" — into the sex type:
{
"name": "Sex from Garmin",
"mode": "retype",
"conditions": [{ "kind": "activity", "activity_type": "other_workout", "title": "sex" }],
"output_activity_type": "sex",
"output_title": "Sex"
}- A synced activity (Garmin, Health Connect, …) gets an aurboda override carrying the new type, exactly like a manual type change, so the next sync of the source row does not undo it. An aurboda activity is changed in place.
output_title, when set, replaces the title;output_dataonly fills fields that are missing.data._retyped_byrecords the rule id.- Activities already overridden are reached through their override, rows produced by rules are never retyped, and an activity already of the target type is skipped, so re-evaluation changes nothing twice.
- The new type's data schema applies, as for a manual type change: if it has required fields the activity lacks,
that activity is not retyped and an audit-log warning names it. Supply them in
output_data. - Deleting or disabling the rule does not change retyped activities back; edit them like any other activity.
When merge_gap_seconds is set, nearby matching time ranges are coalesced into a single activity. For example, with a 10-minute merge gap, two sauna tags 5 minutes apart produce one sauna activity spanning the full period.
Rules are evaluated in priority order (0 first, then 1, then 2). Activities created by priority-0 rules are visible to priority-1 rules, enabling chaining:
- Priority 0: Tag "sauna" ->
saunaactivity - Priority 1:
saunaactivity + tag "cold_plunge" ->contrast_therapyactivity
Maximum chain depth is 2 (priorities 0, 1, 2).
When a rule is created or updated, it is immediately evaluated against the last 90 days of historical data. Existing activities produced by the rule are cleaned up and re-created.
Simple tag-to-activity conversion:
{
"name": "Sauna sessions",
"conditions": [{ "kind": "tag", "tag_name": "sauna" }],
"output_activity_type": "sauna"
}Multi-condition with merge gap:
{
"name": "Binaural Meditation",
"conditions": [
{ "kind": "activity", "activity_type": "meditation" },
{ "kind": "tag", "tag_name": "Holosync" }
],
"output_activity_type": "binaural_meditation",
"merge_gap_seconds": 300
}Screentime-based activity:
{
"name": "Coding sessions",
"conditions": [{ "kind": "screentime_category", "category": ["Work", "Programming"] }],
"output_activity_type": "coding",
"merge_gap_seconds": 600
}GET /deduction-rules-- List all rulesPOST /deduction-rules-- Create a rule (triggers retroactive evaluation)PATCH /deduction-rules/:id-- Update a rule (re-evaluates retroactively)DELETE /deduction-rules/:id-- Delete a rule and its generated activitiesPOST /deduction-rules/evaluate-- Manually trigger full re-evaluation
MCP tools: list_deduction_rules, add_deduction_rule, update_deduction_rule, delete_deduction_rule, evaluate_deduction_rules.
- Generated activities use
source: "deduction-rule"and store the rule ID indata.rule_id - The unique constraint
(source, activity_type, start_time)prevents duplicate activities - Stale activities (from previous evaluations that no longer match) are automatically cleaned up
- Rule evaluation is debounced per-user (5-second window) when triggered by data syncs
- The
output_activity_typemust reference an existing custom activity type