Skip to content

Intelligence

Intelligence is the recommendations feature that surfaces stale items, possible duplicates, grouping suggestions, and other gentle nudges to keep lists clean. It runs an LLM under the hood, so it depends on a configured AI provider.

The user-facing version of this feature is documented at Suggestions. This page is the operator’s reference.

  1. Configure an AI provider.

  2. Go to Admin > Intelligence.

  3. Toggle Enabled on.

  4. (Optional) Pick an intelligenceModelOverride if you want a different model than the global AI config uses elsewhere.

  5. Let the daily cron tick (0 4 * * *) populate recs, or generate immediately for testing:

    Terminal window
    # Against a running container (no pnpm or node_modules in the image;
    # the CLIs ship bundled at .output/scripts/).
    docker compose exec app node .output/scripts/intelligence-run-once.mjs
    # From a source checkout
    pnpm intelligence:run-once

Ten analyzers are registered. They run in the order below, which is also the order their recs surface within a severity bucket:

AnalyzerIDWhat it doesCalls the LLM?
Primary listprimary-listSurfaces a list picker when the user has no active list marked primary.No
List hygienelist-hygieneCalendar-aware nudges for each upcoming event: convert a mismatched public list, make a matching private list public, or scaffold a missing one. Also flags long-stale public lists.Only for the opt-in rename (see intelligenceListHygieneRenameWithAi)
People I shop forrelation-labelsNudges the user to declare mothers / fathers ahead of Mother’s Day and Father’s Day.No
Stale itemsstale-itemsFlags items untouched for six months or more. Emits a “pick one” group suggestion when the model judges the flagged items to be alternatives.Yes
DuplicatesduplicatesDetects probable duplicate items within a list or across the user’s lists.Yes
GroupinggroupingSuggests or / order item groups from clusters of similar items.Yes
Missing pricesmissing-priceItems the user added a URL to but never priced. Bundled one rec per list.No
Unselected imagesmissing-imageItems where the scraper found candidate images but none was ever picked.No
Stale or unscraped URLsstale-scrapeItems with a URL that was never scraped, or not re-scraped in 120 days. Separate bundles per kind.No
Clothing size & colorclothing-prefsClothing items whose title and notes never pin down size or color. Reads facets the enrichment pre-step already paid for.No

Each analyzer can be individually enabled/disabled via intelligencePerAnalyzerEnabled. Adding a new analyzer ships enabled by default; the per-analyzer map only stores admin overrides.

A batched facet-extraction pre-step runs ahead of the analyzers under the pseudo-analyzer id enrichment. It takes a model override and an enable/disable entry like any analyzer; disabling it leaves the facet-driven analyzers running on whatever rows already exist.

SettingDefaultWhat it does
intelligenceEnabledfalseMaster kill switch.
intelligenceRefreshIntervalDays7Cron regenerates a user’s recs when their last successful run is at least this old. Eligibility is purely interval-based; whether the user has engaged with the last batch doesn’t matter. Also the operator-digest cadence.
intelligenceManualRefreshCooldownMinutes60Per-user rate limit on manual “refresh recs” clicks.
intelligenceCandidateCap50Max items per analyzer prompt. Caps token usage on power users.
intelligenceConcurrency3Max parallel users in one cron tick. Avoid hitting provider rate limits.
intelligenceUsersPerInvocation25Distinct users processed per cron tick before bailing. Lower if cron times out.
intelligenceStaleRecRetentionDays30How long to keep recommendations rows before the sweep deletes them.
intelligenceRunStepsRetentionDays30How long to keep recommendation_run_steps (per-step debug rows).
intelligenceDryRunfalseWhen true, runs go through the full pipeline (model calls, step logging) but persist no recommendations rows. Run + run-step rows still write, so admins can debug without affecting users.
intelligenceModelOverridenullOptional model ID override. Provider/key/baseUrl still come from the global AI config; only the model name swaps.
intelligenceAnalyzerModels{}Per-analyzer model map, { "<analyzerId>": "<model>" }. Takes precedence over intelligenceModelOverride. Point cheap analyzers at a small model and leave the rest on the default.
intelligenceEmailEnabledfalseMaster kill switch for all intelligence email.
intelligenceEmailWeeklyDigestEnabledfalseGates the operator digest specifically. Both this and the master must be on.
intelligenceEmailTestRecipientnullWhen set, scheduled digests go to this one address instead of the admins. Also the target of the “Send test now” button.
intelligenceUpcomingWindowDays45List-hygiene: how many days before an event counts as “in window” for nudges.
intelligenceMinDaysBeforeEventForRecs1List-hygiene: floor on days-until-event. Below it, convert / create / privacy recs stop firing, so there’s no same-day churn.
intelligenceListHygieneRenameWithAifalseList-hygiene: opt into an AI rename on the convert branch. Off means the deterministic regex rename. The prompt sees only the list name, target type, and event title/year - never item content, never claim data.
intelligenceStaleListPastEventDays90List-hygiene: days past an event-bound list’s occurrence before it can be flagged stale. Raise to let lists run longer.
intelligenceStaleListInactiveMonths12List-hygiene: months of owner inactivity (list or any of its items) before a list can be flagged stale. Applies to wishlists too.

Every run path (cron, manual, CLI) records a row in recommendation_runs with a skip_reason when the run didn’t generate recs. There are four:

skip_reasonWhen
disabledMaster toggle off.
no-providerAI config not resolvable.
lock-heldAnother run is in flight for this user.
cooldownManual only. User hit intelligenceManualRefreshCooldownMinutes.

The Postgres advisory lock is keyed by user, so concurrent triggers for the same user collide rather than double-spending tokens.

Separately from the run-level reasons above, each analyzer hashes its own candidate slice and compares it against the hash from the last real generation for that scope. When they match, the analyzer returns before its model call and its existing rec rows are carried forward untouched. The run itself still succeeds and is recorded as a success. This is a per-analyzer economy, not a run skip, so it never sets a run-level skip_reason.

Each rec has a stable fingerprint (analyzer + kind + sorted target IDs). When a new batch produces a rec with the same fingerprint as a prior dismissed (or applied) rec, the runner carries the status forward instead of creating it as active.

So if you dismiss a “duplicate item pair” rec, that exact pair stays dismissed across regenerations until one of the items is deleted or renamed enough that the fingerprint changes.

The one outbound email that isn’t addressed to an end user: a periodic summary of system-wide Intelligence activity, sent to the deployment’s admins. Configured under Admin > Intelligence > Notifications.

The gating chain - all four must hold, or nothing sends:

  1. intelligenceEnabled (the feature master).
  2. intelligenceEmailEnabled (the master for all intelligence email).
  3. intelligenceEmailWeeklyDigestEnabled (this digest specifically).
  4. Outbound email is configured.

Audience is every user with the admin role, BCC’d so the addresses aren’t exposed to each other. Setting intelligenceEmailTestRecipient redirects the scheduled send to that one address instead, which is the safe way to try it before pointing it at your admins.

Cadence reuses intelligenceRefreshIntervalDays. The digest fires from a post-step of the intelligence cron rather than from the daily email tick, because it summarizes that pipeline. Each send stamps an internal last-sent flag and the next send waits out the interval.

It always sends when gated on and the interval has elapsed, including an all-quiet “no runs this period” digest. That’s deliberate: the heartbeat means an empty inbox is a signal rather than an ambiguity. The caveat is that the heartbeat is only as alive as the intelligence cron itself - if the cron stops firing, so does the digest.

Contents are aggregate: run counts by status, skip-reason breakdown, token and cost rollups, users processed, active recs by analyzer and severity, and a capped “needs attention” list that names users only when they had errors or repeated skips. It never includes recommendation titles or bodies, and never any claim data.

Intelligence honors GiftWrapt’s spoiler-protection rule:

  • giftedItems (claim) data never appears in any prompt sent to a model.
  • Rec body text is human-readable rationale only; never references claim data.
  • items.isArchived = true items are excluded from candidate sets (those are revealed gifts, not list hygiene).
  • giftideas and todos lists are filtered out at the SQL level - gift ideas are spoiler surfaces for someone else, todos have no gift fields to analyze.

Three ways to run the analyzer pipeline; pick whichever fits your stack. They all share the same runner and can run alongside each other safely thanks to the per-user advisory lock.

ShapeWhere it runsWhen to pick it
Bundled HTTP cron/api/cron/intelligence-recommendations, CRON_SECRET-gatedDefault. Works with Vercel Cron, Render Cron, system crontab, the bundled docker compose sidecar.
External schedulerSame endpoint, hit by your own schedulerWhen you want central scheduling outside the app. Same secret.
Standalone workernode .output/scripts/intelligence-worker.mjs (or pnpm intelligence:worker from source)When you want a separate Docker service that owns the analyzer drain. Keep the HTTP cron running too if you like - the lock prevents double-spend.