Skip to main content
Status: draft · Version 0.2 · Filed 2026-04-22

spec_id: SPEC-029 version: “0.2” title: Agent nudges — persistent cross-session reminders for implicit obligations status: draft supersedes: (SPEC-029 v0.1 conversational draft, never filed)

SPEC-029 v0.2 — Agent nudges

1. Summary

Introduces a nudges table: persistent, tool-written prompts that surface across sessions when an agent action creates an implicit obligation a future surface should check on. Ships with five write points and three resolution paths. First-class primitive alongside TODOs / notes / WIP — fills the gap between ephemeral rules_reminders (computed fresh each call) and TODOs (explicit user work). Closes the 2026-04-22 “3 unwrapped deltas went silently stale” failure mode from SPEC-023 §5.4’s heuristic-based detection, and generalizes the fix so the same mechanism handles draft SPECs, proposed ADRs, unsealed WIPs, open invites, and issued handoffs without per-case bespoke logic.

2. Origin

2026-04-22 PM discussion (Frank + Donna). Starting from a failed SPEC-029 v0.1 draft that proposed silent auto-wrap on prism_start, two objections collapsed the design into a better primitive:
  1. SPEC-023 already rejected auto-wrap on start — “produces artifact that looks like wrap but isn’t.” v0.1’s delta_kind='auto_wrap' enum extension was a workaround, not a fix.
  2. Concurrent sessions (Lola on Claude Desktop + Donna on Claude Code) — a time-threshold-based auto-wrap would silently close a live-but-quiet session. No liveness signal exists in the current model.
Frank: “what about a Reminders or Nudge list / table? A table for when an item isn’t quite a Todo but something to remind the tools to ask the user and or make a recommendation.” The pattern resolves both objections structurally: nudges are session_id-scoped (so a wrap only clears its own), and surfacing is user-driven (so no silent side effects).

3. Problem

Three overlapping failure modes existed:
  1. Abandoned sessions stay invisible. SPEC-023 §5.4 detects unwrapped prior sessions by delta-sequence heuristic. The signal is lossy — it rebuilds state from delta history every call, with no persistent marker of “this session owes a wrap.”
  2. Draft-arc abandonment. A SPEC filed as status=draft or an ADR as status=proposed has no future-surface reminder. It lives in prism_*(action=list) output, which agents rarely scan proactively.
  3. Cross-session obligations have no primitive. WIP states, open invites, outstanding handoffs — each is tracked in its own table with its own surfacing convention (if any). No uniform “open things that need attention” query exists.

4. Schema

New table nudges:
ColumnTypeNotes
iduuidPK
tenant_iduuidFK, org scope
project_iduuidFK, null allowed for org-level nudges in a future spec — v1 requires non-null
kindvarchar(32)see §4.1
source_typevarchar(32)what the nudge refers to: session, spec, adr, wip_state, invite, handoff
source_idvarchar(64)polymorphic reference; e.g. session_id, spec_id, adr decision_id
messagetexthuman-readable prompt (“Wrap this session before leaving”)
actionsjsonbarray of action strings, e.g. ["wrap","continue","ignore","snooze"]
severityvarchar(16)info | nudge, default nudge. No blocking — nudges never hard-gate
created_attimestamptzdefault now()
resolved_attimestamptznull while open
resolved_by_verbvarchar(64)e.g. prism_wrap, prism_spec.update, prism_nudge.resolve
resolutionvarchar(32)null while open; completed, ignored, snoozed, auto_cleared
resolution_notetextoptional user note on manual resolution
snooze_untiltimestamptzif resolution=snoozed; nudge re-opens at this time
metadatajsonbkind-specific extras (e.g. {"persona":"Lola","delta_count":3})
Indexes:
  • (tenant_id, project_id, resolved_at) partial WHERE resolved_at IS NULL — fast open-nudge listing
  • (kind, source_type, source_id) — fast auto-resolution lookup
  • (snooze_until) partial WHERE resolution='snoozed' — reopener sweep
Invariant: append-never-delete. Resolution sets resolved_at and resolution; the row stays on disk. Re-opening a snoozed nudge creates a new row with a reference to the prior one in metadata rather than mutating the resolved row. (Mirrors SPEC-024 projection-retirement ethos.)

4.1 Nudge kinds (v1)

kindWritten bysource_typesource_idResolves on
wrap_sessionprism_startsessionsession_id minted by that startprism_wrap with matching session_id
spec_draftprism_spec(create, status=draft)specspec_idprism_spec(update, status=accepted|superseded|rejected)
adr_proposedprism_decide(status=proposed)adradr decision_idprism_decide(update, status=accepted|superseded|rejected)
wip_unsealedprism_wip createwip_statewip_state_uuidprism_seal with matching uuid
invite_pendingprism_invite createinviteinvite_idprism_accept_invite or invite decline/expiry
handoff_pendingprism_handoffhandoffhandoff doc idmanual resolve via prism_nudge (no auto-ack verb yet)
Scope lock for v1: these six. Agent writers do not invent new kind values; new kinds require a spec amendment. This prevents “write a nudge for anything” pollution.

5. Write semantics

Writers MUST:
  1. Use the kind’s canonical source_type + source_id per §4.1.
  2. Idempotence: if an open nudge already exists with the same (kind, source_type, source_id), do not write a duplicate. The write path is an UPSERT on resolved_at IS NULL — existing open row wins, new write is a no-op. Audit counter in metadata optional.
  3. Failure-soft: nudge write errors must NEVER fail the parent verb. Log-and-proceed. (Matches prism_wrap patch_project pattern, mcp/server.py:1618–1634.)
Writers MUST NOT:
  • Write a nudge with a kind not in §4.1.
  • Write a nudge whose source_id refers to an entity that doesn’t exist.
  • Write a nudge with severity=blocking (reserved for future spec; v1 enum is info | nudge only).

6. Resolution semantics

Three resolution paths:

6.1 Auto-resolution on state change (preferred)

When the resolving verb fires with matching (kind, source_type, source_id), the server UPDATES open nudge rows in the same transaction:
UPDATE nudges
   SET resolved_at = now(),
       resolved_by_verb = '<verb>',
       resolution = 'auto_cleared'
 WHERE kind = '<kind>'
   AND source_type = '<source_type>'
   AND source_id = '<source_id>'
   AND resolved_at IS NULL;
For prism_wrap: (kind='wrap_session', source_type='session', source_id=<current session_id>). Critical: matches current session’s session_id only — structurally solves the concurrent-Lola-and-Donna case because Lola’s open wrap_session nudge has a different source_id.

6.2 Manual resolution (user-initiated)

New verb prism_nudge:
prism_nudge(pid, action, nudge_id?, resolution?, resolution_note?, snooze_hours?)
Actions:
  • list — returns open nudges for the project (optional filter by kind).
  • resolve — sets resolved_at, resolution=ignored|completed|snoozed, resolved_by_verb='prism_nudge.resolve', plus optional note.
  • snooze — shortcut for resolve with resolution='snoozed' plus snooze_until = now() + snooze_hours.

6.3 Scheduled re-opening (snooze sweep)

A background sweep (or on-read check) finds nudges with resolution='snoozed' AND snooze_until <= now() and writes a fresh open row with the same (kind, source_type, source_id) plus metadata.reopened_from = <prior row id>. The prior resolved row stays on disk per the append-never-delete invariant. v1 implementation: on-read check inside prism_start’s open-nudge query (WHERE resolved_at IS NULL OR (resolution='snoozed' AND snooze_until <= now()) — no separate background worker needed). The fresh-row write happens on the next prism_start that observes the expiry. Simple and avoids cron infrastructure.

7. Surfacing contract

prism_start response adds a top-level pending_nudges array:
"pending_nudges": [
  {
    "id": "...",
    "kind": "wrap_session",
    "source_id": "<prior session_id>",
    "message": "Prior session has 3 unwrapped deltas, last activity 2026-04-22T11:41Z",
    "actions": ["wrap", "continue", "ignore", "snooze"],
    "metadata": { "persona": "Lola", "delta_count": 3,
                  "orphan_summaries": ["docs-hero-diagram-rewrite ...",
                                        "docs-overview-rewrite-and-vision-plp ...",
                                        "docs-history-shipped ..."] }
  }
]
Agent contract (propagated via prism_sync_bios into CLAUDE.md / AGENTS.md under the Session Status Card section):
If pending_nudges is non-empty in the prism_start return, the agent MUST present the open nudges at the top of the first substantive response, with their available actions clearly offered to the user. The agent proceeds with the user’s current request only after either (a) the user has chosen an action for each nudge, or (b) the user has explicitly said to defer (“later”, “ignore for now”), in which case the agent writes a snooze with a sensible default (1 hour) and proceeds.
rules_reminders keeps its computed entries (wrap_rate, BIOS drift) and gains a nudges sibling:
"rules_reminders": [ /* computed, ephemeral */ ],
"pending_nudges": [ /* persistent, actionable */ ]
SPEC-023’s _check_recent_unwrapped_sessions is removed — its job is now done by the wrap_session nudge lifecycle. Migration note: on first deploy, run a one-shot backfill that writes wrap_session nudges for any historical unwrapped sessions in the last 30 days (see §10.2).

8. Agent contract: when to write a nudge

Writer tools follow this decision tree:
  1. Does the action create an implicit obligation a future surface should check on? If no → no nudge.
  2. Is the obligation cheaply recomputable at read time? If yes → keep in rules_reminders (ephemeral, no storage).
  3. Is the obligation an explicit user work item? If yes → use prism_todo, not a nudge.
  4. Is the obligation scoped to a specific entity’s lifecycle that already has a resolution verb? If yes → nudge with the matching kind per §4.1.
This is the rubric — not a test. Agent judgment applies. But the v1 writers are locked to the six kinds in §4.1.

9. Rejected alternatives

  • SPEC-029 v0.1 silent auto-wrap — superseded by this spec. Objection: SPEC-023’s “artifact that looks like wrap but isn’t”; concurrency race with live-but-quiet sessions.
  • Staleness threshold on the writer side — pushes “is this abandoned?” judgment to wall-clock math. Unreliable for live-but-idle sessions. Nudges defer that judgment to the user at read time instead.
  • Reuse session_deltas as nudge storage — would overload delta_kind and mix ephemeral nudges with append-only history. Separate table keeps semantics clean.
  • Blocking on start (hard-gate until user resolves) — violates SPEC-021’s soft-gate ethos; breaks scripted use. Kept as soft surfacing contract only.
  • Scope-small v1 (wrap nudge only, defer others) — Frank: “I don’t want to create something else I need to come back later to complete. Only defer when I have to.” The five other kinds are trivial write hooks once the table exists; no reason to defer.

10. Implementation

10.1 Sequencing — single PR (est. 1 day)

StepScope
1Migration: nudges table + indexes.
2Backend model + schemas: Nudge, NudgeCreate, NudgeResolve.
3Service layer: create_nudge (idempotent UPSERT), resolve_nudges_for, list_open_nudges, sweep_expired_snoozes (on-read variant).
4Router: POST /nudges, POST /nudges/{id}/resolve, GET /projects/{pid}/nudges.
5MCP verb prism_nudge (actions: list, resolve, snooze).
6MCP integration: hook into prism_start, prism_wrap, prism_spec, prism_decide, prism_wip, prism_seal, prism_invite, prism_accept_invite, prism_handoff per §4.1 + §6.1.
7Remove SPEC-023 _check_recent_unwrapped_sessions detector; pending_nudges replaces it. Keep wrap_rate computed reminder (that’s still programmatic, not nudge-shaped).
8BIOS template update (CLAUDE.md / AGENTS.md) with §7 agent contract. Propagate via prism_sync_bios.
9Smoke tests: mcp/smoke_spec029.py covering all six kinds’ write + resolve + snooze cycles.

10.2 Historical backfill (one-shot, same migration)

On first deploy, write wrap_session nudges for any session_ids in session_deltas from the last 30 days where:
  • At least one non-wrap delta exists
  • No wrap delta exists for that session_id
Backfill runs idempotent (UPSERT on open). Bounded window prevents unbounded history sweep on very old projects.

10.3 Observability

  • Counter: nudges_opened_total{kind} (30d rolling)
  • Counter: nudges_resolved_total{kind, resolution} (30d rolling)
  • Gauge: nudges_open{kind} (current open count per kind)
  • Derived: per-kind median age, resolution rate, snooze rate.
Added to the existing observability surface (wherever SPEC-023 wrap_rate surfaces).

10.4 Install / deploy impact

Zero install script changes. backend/docker-entrypoint.sh runs alembic upgrade head on boot; the migration applies on next backend restart. No env var additions. No compose file changes.

11. Relationships

  • Extends SPEC-023 — replaces its §5.4 detection heuristic with persistent nudge rows. SPEC-023’s delta_kind column, session_id minting, and wrap_rate observability all stand.
  • Uses SPEC-022’s last_wrapped_* columns unchanged. Auto-resolution of wrap_session on prism_wrap still patches those columns per SPEC-022 before resolving the nudge.
  • Supersedes SPEC-029 v0.1 (conversational draft; never filed; preserved in session deltas).
  • Related SPEC-021 Ring 0 — nudge surfacing uses the status-card / bootstrap contract as its delivery channel but does not add new gates.
  • Informs future spec on org-level nudges (project_id=null) — deliberately scoped out of v1.

12. Open questions

  • Q1: Default snooze duration — 1h in the agent contract (§7). Tune with usage; revisit after 30d of data.
  • Q2: Should handoff_pending auto-resolve on the next persona’s prism_start (as an implicit ack)? Lean no: handoff acknowledgment should be explicit. Defer to follow-up spec.
  • Q3: Should spec_draft and adr_proposed nudges self-snooze if the draft is being actively edited (delta activity in the last hour referencing the spec_id)? Lean no in v1; nudges are about cross-session memory, not intra-session noise. Revisit if users complain.
  • Q4: Do we need an org_nudges table or can org-scoped nudges reuse this table with project_id=null? Lean reuse with null. Defer until first org-level use case.
  • Q5: Per-project agent-contract overrides (e.g. “this project: block on wrap_session, don’t for other kinds”) — deferred to follow-up SPEC. v1 is uniform across projects.

13. Acceptance criteria

  1. nudges table exists with all columns + indexes per §4.
  2. prism_start returns pending_nudges array; empty when no open nudges exist.
  3. prism_wrap on session_id X auto-resolves any open wrap_session nudges with source_id=X and does not resolve nudges with different source_ids (concurrency test).
  4. ✅ Writing two wrap_session nudges for the same session_id in a row results in exactly one open row (idempotence).
  5. ✅ All six kinds in §4.1 have integration tests: write, auto-resolve on matching verb, manual resolve via prism_nudge.
  6. ✅ Historical backfill (§10.2) populates expected wrap_session rows for known unwrapped session_ids on the Prism project.
  7. ✅ Smoke test mcp/smoke_spec029.py passes locally + against server1.
  8. ✅ BIOS templates updated with agent contract; propagated via prism_sync_bios.
  9. rules_reminders no longer emits unwrapped_session entries; those are replaced by pending_nudges entries of kind=wrap_session.
  10. ✅ Wrap-rate metric (SPEC-023 §7) unchanged behaviorally.
Status: draft
Last modified on April 27, 2026