---
title: SMS short-code name inference (auto-fill descriptive contact names)
---
# SMS short-code name inference

## What it is

### What it does

When an SMS conversation comes from a **short code** (a 3–6 digit sender like `62438` or `22395`, no `+` prefix), Omniscio auto-fills a descriptive contact name for it — `62438` becomes `"eBay security code"`, `22395` (which mixes Amazon, eBay, and Walmart codes) becomes `"Verification codes (multi-brand)"`. The inference runs against the conversation's recent message bodies via Qwen3-32B on OpenRouter (~$0.0001/call, same path as title generation), and the result is stored alongside any manual contact name you set. Manual edits always win — the AI's guess is shown only when no human-set name exists, and the moment you save a name it sticks forever.

### What you see

In the SMS sidebar, short-code conversations now show a friendly label instead of a raw number. New users get the inferred names backfilled the first time Omniscio starts up after the feature ships — within ~30 seconds of launch you'll see the names appear in place. Numbers with a manual `contactName` already set are untouched. Numbers that aren't short codes (regular phone numbers like `+1 555-…`) keep their existing display behavior — inference is short-code-only.

When you open a conversation whose displayed name is the AI's guess, a small **pencil icon** appears in the viewer header next to the name. Click it to swap the title for an inline input pre-filled with the inferred name. Type your correction, press **Enter** to save, or **Escape** to cancel. After save, the pencil disappears — your manual name has taken over and the AI guess is no longer used. The pencil never appears for conversations that already have a manual name (nothing to fix) or for non-short-code numbers (no inferred name in the first place).

If a short code carries verification codes from multiple brands (heuristic: 2+ recognizable brand keywords across the recent messages — Amazon + eBay + Walmart, etc.), the model is **skipped entirely** and the conversation is labeled `"Verification codes (multi-brand)"` literally. This is both a cost saver and a more honest label — there's no single brand to name.

## Where to find it

In the SMS sidebar: short-code conversations show a friendly label instead of a raw number, and the pencil icon beside the name lets you correct it.

## How it behaves

### Triggers

Inference runs in five situations, all non-blocking:

1. **Per-message** — every inbound SMS calls `inferIfNeeded(phoneNumber)` from the message-insert path in [/src/main/db/queries-sms/messages.ts](/src/main/db/queries-sms/messages.ts) via dynamic import (avoids a circular dep). The service then walks five skip rules: not a short code → skip; manual contact_name set → skip; no messages yet → skip; already inferred AND no keyword shift in the last 3 messages → skip; daily cost cap hit → skip. Only conversations that pass every rule reach the model.
2. **Smart re-infer** — the `hasKeywordShift` heuristic detects when recent messages introduce a new brand keyword that wasn't in the previous batch (e.g. `62438` started as eBay-only and now carries USPS codes too). When that happens, even an already-named conversation gets re-inferred so the label keeps up.
3. **App startup backfill** — `backfillAll()` runs once at launch over every short-code conversation that has no `inferred_contact_name` yet. This is what populates the sidebar names on first run after the feature ships and after any future schema reset.
4. **Manual force re-infer** — `forceReinfer(phoneNumber)` bypasses every skip rule. Wired through but not exposed in the UI yet (a future "Re-guess name" button could call it).
5. **Transient-failure auto-retry** — when an attempt can't _reach_ the model (request timeout, open circuit breaker, or offline — distinct from the model responding "I can't name it"), the service schedules a bounded, backed-off retry via `scheduleTransientRetry`: up to 3 attempts at ~1 → 2 → 4 min, then it gives up. A definitive empty / `UNKNOWN` result is **not** retried (re-billing a genuinely-unnameable code every cycle would burn the cost cap). This closes a gap where a one-message short code — e.g. a single verification code that never texts again — whose first call timed out stayed a raw number forever, re-attempted only on a new inbound message or the next startup backfill. The retry timer is `unref`'d so it never blocks app shutdown; a retry lost to a restart is recovered by trigger 3. Locked by [the retry contract](../../.claude/memory/contracts/sms-name-inference-retry-contract.md).

### Storage

Migration **v157** adds an `inferred_contact_name TEXT NULL` column to the `sms_conversations` table. The display fallback chain is centralized in [/src/renderer/src/lib/phone-format.ts](/src/renderer/src/lib/phone-format.ts):

```
displayContactName(conv) =
  conv.contactName        // manual override — wins always
  ?? conv.inferredContactName  // AI's guess — shown when no manual name
  ?? formatPhoneNumber(conv.phoneNumber)  // raw fallback (see short-code note)
```

**Short-code raw display.** When the fallback runs for an as-yet-unnamed short code, `formatPhoneNumber` short-circuits to the **raw digits** (`24273`) and skips libphonenumber entirely. Without that, a short code whose leading digits match a NANP area code (e.g. `242` = Bahamas) was parsed as a real number and shown as a misleading `+1 24273`. The check is a renderer-local `isShortCode` mirroring main's `phone-utils.isShortCode` (main's file imports Electron and can't cross into the renderer — the same reason `getDefaultCountryRegion` is duplicated there); keep the two in sync.

Manual edits go through the `SMS_UPDATE_CONTACT_NAME` IPC channel ([/src/shared/ipc-channels/index.ts](/src/shared/ipc-channels/index.ts)), which writes `contactName` and leaves `inferredContactName` intact. NULL-safe re-infer means a model returning empty/garbage never overwrites a known-good name — the existing inferred name is preserved.

### Cost

Each inference call hits Qwen3-32B on OpenRouter (the same cheap model Omniscio's title generation uses). Per-call cost is roughly **$0.0001**. Spend is logged to `api_cost_log` under the source label `'sms-name-infer'` via the existing `trackApiCost` infrastructure, so it shows up in the Cost dashboard alongside every other Anthropic/OpenRouter call.

A **daily spend cap** lives at Settings → SMS → "AI name inference daily cost cap (USD)" — default `$0.10`, range `$0.05` – `$5`. The pre-flight check in `inferIfNeeded` blocks the model call once cumulative `'sms-name-infer'` spend for the calendar day hits the cap; inference resumes the next day. A first-launch backfill across, say, 50 short-code conversations costs roughly $0.005 — well under the default cap.

### Failure modes

- **Cost cap hit.** The service logs `[sms-name-infer] daily cost cap hit ($X.XX) — skipping inference for <phoneNumber>` and returns early. The conversation keeps its existing `inferred_contact_name` (or stays without one). Inference resumes automatically on the next calendar day.
- **Model returns empty / too short (DEFINITIVE).** NULL-safe re-infer: the existing `inferred_contact_name` is left alone (don't overwrite a known-good name with garbage). This is a _definitive_ outcome — the model responded but couldn't name it — so it is **not** auto-retried (a timer retry would just re-ask about a genuinely-unnameable code and burn the cost cap). With no prior name, the conversation stays unnamed and falls back to the raw phone display; a later inbound message or a keyword shift can still re-trigger it.
- **Network down / OpenRouter unreachable / timeout (TRANSIENT).** The model couldn't be _reached_, so `inferSmsContactName` returns `{ transient: true }` and the service schedules a bounded backed-off retry (trigger 5 — up to 3 attempts, ~1 → 2 → 4 min) instead of stranding the conversation. Until a retry lands it falls through to the raw phone display in `displayContactName`. An open circuit breaker counts as transient too. (Before this, a timed-out call was treated like "no name" and only re-tried on the next inbound message — a one-shot short code stayed a number.)
- **Multi-brand detected.** `detectMultiBrand` short-circuits before the model call — no API spend, label written as `"Verification codes (multi-brand)"`. This is the expected outcome for shared short codes (some carriers route multiple brands through the same number).
- **Pencil-edit save fails.** The viewer toasts `"Failed to save name: <error>"` via the toast store and stays in edit mode so you can retry. The IPC call is the single source of truth — until it succeeds, no `contactName` is written, so the inferred-name display is preserved.

## For agents

### Code map

- Service: [/src/main/services/sms/sms-name-inference-service.ts](/src/main/services/sms/sms-name-inference-service.ts) — `inferIfNeeded`, `forceReinfer`, `backfillAll`; `scheduleTransientRetry` + `transientRetryAttempts` (the bounded retry, `MAX_TRANSIENT_RETRIES` / `RETRY_BASE_DELAY_MS`)
- Heuristics: [/src/main/services/sms-name-inference/heuristics.ts](/src/main/services/sms-name-inference/heuristics.ts) — `detectMultiBrand`, `hasKeywordShift`
- Short-code check: [/src/main/services/phone-utils.ts](/src/main/services/phone-utils.ts) — `isShortCode` (main); mirrored renderer-local copy in the display helper below
- Model call: [/src/main/services/ai/ai-suggestion-service.ts](/src/main/services/ai/ai-suggestion-service.ts) — `inferSmsContactName(phoneNumber, recentMessages)` returns the name (string), `null` (definitive), or `{ transient: true }`; `isTransientFailureReason` classifies the `callAi` fallback reason
- DB queries: [/src/main/db/queries-sms/conversations.ts](/src/main/db/queries-sms/conversations.ts) — `setInferredContactName`, `listShortCodeConversationsNeedingInference`, `getConversationByPhone`; `insertMessage` ([messages.ts](/src/main/db/queries-sms/messages.ts)) triggers `inferIfNeeded` via dynamic import
- IPC channel: `SMS_UPDATE_CONTACT_NAME` in [/src/shared/ipc-channels/index.ts](/src/shared/ipc-channels/index.ts)
- Display helper: [/src/renderer/src/lib/phone-format.ts](/src/renderer/src/lib/phone-format.ts) — `displayContactName`; `formatPhoneNumber` (short-circuits a bare short code to raw digits via the renderer-local `isShortCode`)
- Pencil edit UI: [/src/renderer/src/features/sms/SmsViewer.tsx](/src/renderer/src/features/sms/SmsViewer.tsx) — `showPencil`, `startEditName`, `saveName`, `cancelEditName`
- Settings: declarative entry in [/src/renderer/src/features/settings/sections/sms/SmsSettings-definitions.ts](/src/renderer/src/features/settings/sections/sms/SmsSettings-definitions.ts) (`settingsKey: 'smsNameInferCostCapUsdPerDay'`)
- Migration: v157 in [/src/main/db/incremental-migrations.ts](/src/main/db/incremental-migrations.ts)

## Related

- [sms-name-inference-retry-contract.md](../../.claude/memory/contracts/sms-name-inference-retry-contract.md) — the test-locked invariants for the transient-retry distinction and the short-code raw display.
- [set-up-sms-integration.md](set-up-sms-integration.md) — the underlying SMS integration (Pushbullet or native) this feature decorates.
- [workflow-coach.md](workflow-coach.md) — same daily-spend-cap pattern (per-day Haiku budget with a pre-flight check); the Workflow Coach was the canonical reference implementation copied here.
