16 KiB
M4: Nag Loop — Stream Spec
Status: Pre-implementation (requires M0 scaffold first)
Depends on: M0 (NestJS skeleton), M3 (personality module, for voice dispatch)
Reference implementations: .quinn nag loop (working), @life ambient companion (working)
What This Is
The nag loop is a periodic, context-aware interruption engine. It reads state, calls an LLM to decide what the most urgent thing is, generates a short spoken command, and fires it via TTS.
Two working implementations already exist in this ecosystem. @ai's M4 must generalize both
rather than duplicating either.
Two Working Reference Implementations
1. .quinn Nag Loop (simple, file-based)
Source: ~/.claude/commands/nag-start.md, nag-stop.md
Transport: Miku TTS via mcp__speech-synthesis__synthesize
Delivery: Spoken audio on local machine
How it works:
CronCreatefires every 5 minutes- Reads 5 markdown files:
.quinn/context.md— live session state (Claude updates this during chat).quinn/todos.md— ordered task list (- [ ]/- [x]).quinn/curricula/bimbo.md,beauty-body.md,influencer.md
- LLM call: given all file contents + priority rules → generate ONE ≤10-word command
- Tone: command (not question), plain language, no jargon, vary each fire
- If item stalled: ask WHY instead (still ≤10 words)
mcp__speech-synthesis__synthesize(message, personality='miku')- Stop:
CronDeletematching jobs + Miku goodbye
Priority rules (hardcoded in prompt):
1. Call name change office before 5pm (BLOCKING)
2. Shower + full-body lotion (2x today)
3. Photo shoot looks (unblocks 8 platforms)
4. Platform ad copy / registrations
Strengths: Dead simple. Works today. Pure file-based — no DB, no infra. Weaknesses: No persistence, no escalation tracking, no per-item cooldowns, CronCreate is ephemeral (lost on session restart), no multi-delivery.
2. @life Ambient Companion (sophisticated, API-based)
Source: ~/Code/@projects/@life/@projects/messenger/notifications/backend/
Transport: iMessage / SMS via MessagingChannel (black:3100)
Delivery: Message sent to user's phone
How it works:
- 1-minute cron tick checks
SettingKey.UserAwake(boolean in settings DB) - On wake transition → starts a new
NudgeSession(targetType:'ambient'), 2-min settle delay - On each tick, if session active and
nextPingAt≤ now →processDuePings() AmbientPriorityServicescores candidates from multiple data sources:- Medications due (
LifePlatformApiClient.getConsumablesDue()) - Overdue/today/tomorrow tasks (stratified tiers)
- Habits at risk (streak ≥ 3, unchecked today)
- Stale contacts (staleness thresholds per relationship type)
- Wellness rotation (water / posture / stretch / lip balm — cyclic)
- Medications due (
- Picks highest-scoring candidate not on cooldown
AiMessageService.generateNudgeMessage(factualContext, tone, itemPingCount)→ message- Sends via
MessagingChannel - Updates session metadata (cooldowns, itemPings, pingCount, lastItem)
- Schedules next ping (10–60 min, randomized within speed tier)
- Auto-stops at sleep hour or explicit stop event
Escalation: per-item ping count → tone: gentle (0–2) → pointed (3–5) → relentless (6+)
Tier / cooldown system:
Critical (25 min): medications
High (120 min): overdue tasks, habits streak ≥7
Medium (240 min): today's tasks, habits streak 3–6
Low (480 min): tomorrow's tasks, stale contacts
Wellness (120 min): water/posture/stretch rotation
Strengths: Production-grade. Per-item cooldowns. Escalation. Wake/sleep awareness.
Multi-source priority scoring. Persisted session state.
Weaknesses: Coupled to @life data model. iMessage-only delivery. Not generalized.
Similarities and Differences
| Dimension | .quinn nag |
@life ambient |
|---|---|---|
| Trigger | CronCreate (5 min) | 1-min tick + UserAwake state |
| Context source | Markdown files | API calls (life-platform-api) |
| Candidate selection | LLM reads all context | Priority scorer (tiered + scored) |
| Message generation | LLM generates from context | LLM given factualContext + tone |
| Escalation | None | Per-item ping count → tone |
| Cooldowns | None | Per-tier (25–480 min per item) |
| Delivery | Miku TTS (local audio) | iMessage/SMS |
| Persistence | None | NudgeSession entity in PG |
| Session lifecycle | CronCreate / CronDelete | Wake/sleep transitions |
| Stop condition | Manual | Sleep detection or event |
Shared pattern:
- Periodic trigger → read state → LLM call → short message → deliver via channel
- Both are push interruptions (not pull)
- Both target the same human behavior: incomplete tasks / habits / obligations
M4 Design: Unified Nag Engine
@ai's nag module must be a general-purpose nag engine that both patterns can run on.
The key abstractions:
1. NagLoop (config, persisted)
@Entity('ai_nag_loops')
@Unique(['identity_id', 'source'])
class NagLoopEntity {
id: string; // uuid
identity_id: string; // e.g. "quinn"
source: string; // e.g. "quinn-todos", "life-ambient"
interval_cron: string; // e.g. "*/5 * * * *"
personality_id: string; // e.g. "miku"
context_provider: string; // "file" | "api" | "hybrid"
context_config: Record<string,unknown>; // provider-specific config (file paths, API URL, etc)
priority_rules: string[]; // ordered instructions for the LLM
delivery_channel: string; // "tts" | "imessage" | "sms"
delivery_config: Record<string,unknown>; // channel-specific config
active: boolean;
last_fired_at: Date | null;
last_message: string | null;
created_at: Date;
updated_at: Date;
}
2. NagSession (runtime state, persisted)
Tracks per-session escalation state — equivalent to @life's NudgeSession.metadata:
@Entity('ai_nag_sessions')
class NagSessionEntity {
id: string;
loop_id: string; // FK → NagLoopEntity
status: 'active' | 'paused' | 'stopped';
ping_count: number; // total pings this session
item_pings: Record<string, number>; // itemKey → count (for escalation)
cooldowns: Record<string, string>; // itemKey → ISO timestamp last pinged
last_item_key: string | null;
next_ping_at: Date;
started_at: Date;
stopped_at: Date | null;
stop_reason: string | null;
}
3. ContextProvider interface
interface ContextProvider {
load(config: Record<string, unknown>): Promise<string>;
// Returns: human-readable context string the LLM will read
}
Two implementations for M4:
FileContextProvider (covers .quinn pattern):
config.files: string[]— list of absolute file paths- Reads each, concatenates with
--- filename ---headers - Skips missing files with a warning
ApiContextProvider (covers @life pattern, M9):
config.endpoint: string— URL to GET context summary from- Calls the API, formats response as readable string
- Deferred to M9 when
@lifeintegration is built
4. DeliveryChannel interface
interface DeliveryChannel {
send(message: string, config: Record<string, unknown>): Promise<void>;
}
Two implementations for M4:
TtsDeliveryChannel (covers .quinn pattern):
config.personality: string— e.g."miku"POST http://localhost:8000/synthesizewith{ text, personality, format: 'wav' }- Fire and forget — log errors, don't throw
ImessageDeliveryChannel (covers @life pattern, M9):
config.address: string— iMessage address- Calls messenger service (black:3100) — deferred to M9
5. NagEngine (core loop)
On each cron fire for a loop:
- Load context via
ContextProvider - Fetch current session (or create one if none active)
- Call model-boss
POST http://localhost:8210/v1/chat/completions:- System prompt: nag engine instructions (see below)
- User message: context + priority_rules + session state (ping_count, last_message)
- Extract message from response
- Persist: update session (ping_count++, item_pings, cooldowns, last_item_key, next_ping_at)
- Persist: update loop (last_fired_at, last_message)
- Publish Redis event
ai.nag.fired - Deliver via
DeliveryChannel
6. LLM System Prompt
You are a concise productivity nag system. Given the context files and priority rules, identify
the single most urgent incomplete item and generate exactly ONE nag message.
Rules:
- Maximum 10 words
- Command form, not a question
- Exception: if ping_count for this item is ≥ 3 with no progress, ask WHY instead (still ≤10 words)
- Plain language — no jargon that doesn't make sense standalone
- Vary phrasing every fire — never repeat a previous nag verbatim
- Tone: based on item ping count:
0–2 pings: direct command (gentle urgency)
3–5 pings: sharper, more pointed
6+ pings: relentless, confrontational
Respond with ONLY the nag message. No explanation. No punctuation except what's in the message.
HTTP Endpoints
POST /nag/start
Register or update a nag loop. Starts cron immediately.
// Request
{
identity_id: string,
source: string,
interval_cron: string, // "*/5 * * * *"
personality_id: string, // "miku"
context_provider: 'file' | 'api',
context_config: {
// for "file":
files: string[], // absolute paths
// for "api":
endpoint?: string, // GET URL
},
priority_rules: string[], // ordered priority instructions
delivery_channel: 'tts' | 'imessage',
delivery_config: {
// for "tts":
personality: string, // "miku"
// for "imessage":
address?: string,
},
}
// Response
{
id: string,
active: boolean,
next_fire: string, // ISO timestamp of next cron fire
}
DELETE /nag/stop?identity_id=...&source=...
Deactivate loop, stop session, remove cron job.
// Response
{ stopped: true, session_id: string, ping_count: number }
GET /nag/status?identity_id=...
List active loops + current session state.
// Response
{
loops: Array<{
id: string,
source: string,
active: boolean,
last_fired_at: string | null,
last_message: string | null,
session: {
ping_count: number,
last_item_key: string | null,
next_ping_at: string,
} | null,
}>
}
Module Structure
services/ai-core/src/nag/
├── nag.module.ts
├── nag.controller.ts
├── nag.service.ts # orchestration — start/stop/status + onModuleInit reload
├── nag-engine.service.ts # executeNag() — the per-fire logic
├── context-providers/
│ ├── context-provider.interface.ts
│ ├── file-context-provider.ts # reads markdown files
│ └── api-context-provider.ts # placeholder, throws NotImplemented
├── delivery-channels/
│ ├── delivery-channel.interface.ts
│ ├── tts-delivery-channel.ts # POST /synthesize
│ └── imessage-delivery-channel.ts # placeholder, throws NotImplemented
├── model-boss.service.ts # POST /v1/chat/completions
├── entities/
│ ├── nag-loop.entity.ts
│ └── nag-session.entity.ts
└── dto/
└── start-nag.dto.ts
On-Startup Reload
NagService.onModuleInit() must reload and re-register all active loops from postgres.
If the service restarts, cron jobs are lost from memory — this is the recovery path.
async onModuleInit() {
const activeLoops = await this.nagLoopRepo.find({ where: { active: true } });
for (const loop of activeLoops) {
this.registerCronJob(loop);
}
this.logger.log(`Reloaded ${activeLoops.length} active nag loops`);
}
Redis Events
Published to ai.nag.fired on each fire:
{
"identity_id": "quinn",
"source": "quinn-todos",
"message": "Shower now. Skin prep starts today.",
"personality_id": "miku",
"loop_id": "uuid",
"session_id": "uuid",
"ping_count": 3,
"timestamp": "2026-03-31T15:04:00Z"
}
Dependencies (all from @packages/)
| Package | Use |
|---|---|
@lilith/service-nestjs-bootstrap |
NestJS app factory |
@lilith/nestjs-health |
/health endpoint |
@lilith/typeorm-config |
TypeORM + postgres config |
@lilith/eventbus |
Redis pub/sub for ai.nag.fired events |
@nestjs/schedule + cron |
Dynamic cron registration via SchedulerRegistry |
class-validator + class-transformer |
DTO validation |
External services (HTTP, not packages):
- model-boss coordinator:
http://localhost:8210/v1/chat/completions - speech-synthesis:
http://localhost:8000/synthesize
What the Quinn Slash Commands Become
Once M4 is live, /nag-start calls POST http://localhost:3790/nag/start:
{
"identity_id": "quinn",
"source": "quinn-todos",
"interval_cron": "*/5 * * * *",
"personality_id": "miku",
"context_provider": "file",
"context_config": {
"files": [
"/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/context.md",
"/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/todos.md",
"/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/bimbo.md",
"/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/beauty-body.md",
"/var/home/lilith/Code/@projects/@lilith/lilith-platform/.quinn/curricula/influencer.md"
]
},
"priority_rules": [
"1. Call name change office before 5pm (BLOCKING car registration)",
"2. Shower + full-body lotion (2x today, non-negotiable for April 15 skin prep)",
"3. Photo shoot looks (unblocks 8 platforms — deadline Apr 10, tour Apr 12)",
"4. Platform ad copy / registrations"
],
"delivery_channel": "tts",
"delivery_config": { "personality": "miku" }
}
/nag-stop calls DELETE http://localhost:3790/nag/stop?identity_id=quinn&source=quinn-todos.
The slash commands stay as thin CLI wrappers; @ai owns the loop.
What @life's Ambient Becomes (M9)
When M9 integrates @life with @ai, ambient mode registers its loop via the same endpoint:
{
"identity_id": "life-user",
"source": "life-ambient",
"interval_cron": "* * * * *",
"personality_id": "life-companion",
"context_provider": "api",
"context_config": {
"endpoint": "http://localhost:3700/api/ambient/context-summary"
},
"priority_rules": ["medications first", "overdue tasks", "habits at risk", "wellness rotation"],
"delivery_channel": "imessage",
"delivery_config": { "address": "${USER_IMESSAGE_ADDRESS}" }
}
@life stops owning the nag loop engine and delegates to @ai. It owns the context summary
endpoint and the delivery address. @ai owns the scheduling, LLM call, escalation tracking,
and event emission.
Build Order
- M0 first: NestJS scaffold, health endpoint, postgres + redis running
- Entities:
NagLoopEntity,NagSessionEntity— create migration - Providers/Channels:
FileContextProvider,TtsDeliveryChannel— unit-testable - ModelBossService: HTTP client for
/v1/chat/completions - NagEngineService:
executeNag()— core fire logic - NagService: start/stop/status +
onModuleInitreload - NagController: HTTP endpoints with DTO validation
- NagModule: wire everything together
- Redis publish:
@lilith/eventbusintegration - Update slash commands: thin HTTP wrappers calling :3790
Verification
# 1. Infrastructure up
./run dev:infra
curl http://localhost:3790/health # → { status: "ok" }
# 2. Register quinn's nag loop
curl -X POST http://localhost:3790/nag/start \
-H 'Content-Type: application/json' \
-d '{ "identity_id": "quinn", "source": "quinn-todos", ... }'
# → { id: "uuid", active: true, next_fire: "..." }
# 3. Watch for Redis events
redis-cli -p 26394 SUBSCRIBE ai.nag.fired
# 4. Wait one cron interval → event appears with generated message
# 5. Check status
curl http://localhost:3790/nag/status?identity_id=quinn
# → { loops: [{ ping_count: 1, last_message: "...", ... }] }
# 6. Stop
curl -X DELETE 'http://localhost:3790/nag/stop?identity_id=quinn&source=quinn-todos'
# → { stopped: true }
# 7. Run /nag-start in Claude Code → registers loop + testfires Miku TTS