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:
- 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.
- 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:
- 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.”
- 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.
- 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:
| Column | Type | Notes |
|---|
id | uuid | PK |
tenant_id | uuid | FK, org scope |
project_id | uuid | FK, null allowed for org-level nudges in a future spec — v1 requires non-null |
kind | varchar(32) | see §4.1 |
source_type | varchar(32) | what the nudge refers to: session, spec, adr, wip_state, invite, handoff |
source_id | varchar(64) | polymorphic reference; e.g. session_id, spec_id, adr decision_id |
message | text | human-readable prompt (“Wrap this session before leaving”) |
actions | jsonb | array of action strings, e.g. ["wrap","continue","ignore","snooze"] |
severity | varchar(16) | info | nudge, default nudge. No blocking — nudges never hard-gate |
created_at | timestamptz | default now() |
resolved_at | timestamptz | null while open |
resolved_by_verb | varchar(64) | e.g. prism_wrap, prism_spec.update, prism_nudge.resolve |
resolution | varchar(32) | null while open; completed, ignored, snoozed, auto_cleared |
resolution_note | text | optional user note on manual resolution |
snooze_until | timestamptz | if resolution=snoozed; nudge re-opens at this time |
metadata | jsonb | kind-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)
kind | Written by | source_type | source_id | Resolves on |
|---|
wrap_session | prism_start | session | session_id minted by that start | prism_wrap with matching session_id |
spec_draft | prism_spec(create, status=draft) | spec | spec_id | prism_spec(update, status=accepted|superseded|rejected) |
adr_proposed | prism_decide(status=proposed) | adr | adr decision_id | prism_decide(update, status=accepted|superseded|rejected) |
wip_unsealed | prism_wip create | wip_state | wip_state_uuid | prism_seal with matching uuid |
invite_pending | prism_invite create | invite | invite_id | prism_accept_invite or invite decline/expiry |
handoff_pending | prism_handoff | handoff | handoff doc id | manual 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:
- Use the kind’s canonical
source_type + source_id per §4.1.
- 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.
- 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:
- Does the action create an implicit obligation a future surface
should check on? If no → no nudge.
- Is the obligation cheaply recomputable at read time? If yes →
keep in
rules_reminders (ephemeral, no storage).
- Is the obligation an explicit user work item? If yes → use
prism_todo, not a nudge.
- 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)
| Step | Scope |
|---|
| 1 | Migration: nudges table + indexes. |
| 2 | Backend model + schemas: Nudge, NudgeCreate, NudgeResolve. |
| 3 | Service layer: create_nudge (idempotent UPSERT), resolve_nudges_for, list_open_nudges, sweep_expired_snoozes (on-read variant). |
| 4 | Router: POST /nudges, POST /nudges/{id}/resolve, GET /projects/{pid}/nudges. |
| 5 | MCP verb prism_nudge (actions: list, resolve, snooze). |
| 6 | MCP 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. |
| 7 | Remove SPEC-023 _check_recent_unwrapped_sessions detector; pending_nudges replaces it. Keep wrap_rate computed reminder (that’s still programmatic, not nudge-shaped). |
| 8 | BIOS template update (CLAUDE.md / AGENTS.md) with §7 agent contract. Propagate via prism_sync_bios. |
| 9 | Smoke 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
- ✅
nudges table exists with all columns + indexes per §4.
- ✅
prism_start returns pending_nudges array; empty when no open
nudges exist.
- ✅
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).
- ✅ Writing two
wrap_session nudges for the same session_id in a row
results in exactly one open row (idempotence).
- ✅ All six kinds in §4.1 have integration tests: write, auto-resolve
on matching verb, manual resolve via
prism_nudge.
- ✅ Historical backfill (§10.2) populates expected wrap_session rows
for known unwrapped session_ids on the Prism project.
- ✅ Smoke test
mcp/smoke_spec029.py passes locally + against server1.
- ✅ BIOS templates updated with agent contract; propagated via
prism_sync_bios.
- ✅
rules_reminders no longer emits unwrapped_session entries;
those are replaced by pending_nudges entries of kind=wrap_session.
- ✅ Wrap-rate metric (SPEC-023 §7) unchanged behaviorally.
Status: draft