System reminders

System reminders are typed, ephemeral transcript injections for a running agent session. They let Harn add ambient context at turn boundaries, such as token-pressure warnings, idle-session nudges, truncated-tool-output notices, post-compact recaps, or host file-change alerts, without pretending that context came from the user and without adding it to the durable message list.

A reminder is always a structured system_reminder transcript event. It has a stable lifecycle shape, can be deduped, can expire after a finite number of turns, can choose whether it survives compaction, and can state how far it should propagate to sub-agents. Rendering is provider-aware: the same reminder can become developer-role content, system prompt text, or an Anthropic-style <system-reminder> user content block depending on the selected model route. Lifecycle events are observable through EventLog records and host-facing ACP session/update metadata.

What and why

Use reminders when the current agent should keep going, but needs fresh ambient context before its next model turn. Triggers are the different primitive: they start or schedule work. Reminders modify an already running session.

Good reminder candidates are transient facts that should be visible now but not preserved forever:

  • context-window pressure
  • a file changed while the agent was idle
  • a tool result was truncated before the model saw it
  • compaction replaced older turns with a recap
  • workspace memory produced a temporary caution for the current task

Do not use reminders for durable user requirements, audit records, long-lived memory, or background task dispatch. Put those in the normal user/system prompt path, receipts, memory, or triggers.

ReminderSpec shape

Every producer ultimately normalizes to the same ReminderSpec lifecycle fields:

FieldTypeDefaultMeaning
bodystringrequiredReminder text rendered into the next model turn. Must be non-empty for strict producer APIs.
tagslist of strings[]Tags for querying, clearing, diagnostics, and provider conventions.
dedupe_keystring or nilnilNewer pending reminders with the same key replace older pending reminders.
ttl_turnspositive int or nilnilFinite reminders expire after this many post-turn lifecycle passes. nil persists until cleared or compacted away.
preserve_on_compactboolfalseWhen true, compaction copies the reminder event into the compacted transcript.
propagate"all", "session", or "none""session"Controls sub-agent inheritance.
role_hint"system", "developer", "user_block", or "ephemeral_cache""system"Preferred rendering slot; provider capabilities still decide the final wire shape.
source"stdlib_provider", "hook", "bridge", "in_pipeline", or "inherited"producer-specificOrigin marker used by audit and propagation.
fired_at_turnintproducer-specificTurn index when the reminder was created. Pipelines without a turn counter use 0.
idstringgeneratedStable reminder id for audit, replay, and clearing.
originating_agent_idstring or nilnilSet when a reminder is inherited from a parent session.

The transcript event uses the standard event envelope plus a typed reminder payload. metadata mirrors that payload so generic transcript observers can inspect the same fields without special-casing the reminder slot.

{
  "id": "0190abcd-...",
  "kind": "system_reminder",
  "role": "developer",
  "visibility": "public",
  "text": "Approaching context window cap.",
  "blocks": [
    {
      "type": "text",
      "text": "Approaching context window cap.",
      "visibility": "public"
    }
  ],
  "reminder": {
    "id": "0190abcd-...",
    "tags": ["token_pressure"],
    "dedupe_key": "token_pressure",
    "ttl_turns": 3,
    "preserve_on_compact": true,
    "propagate": "session",
    "role_hint": "developer",
    "source": "stdlib_provider",
    "body": "Approaching context window cap.",
    "fired_at_turn": 4
  },
  "metadata": {
    "id": "0190abcd-...",
    "tags": ["token_pressure"],
    "dedupe_key": "token_pressure",
    "ttl_turns": 3,
    "preserve_on_compact": true,
    "propagate": "session",
    "role_hint": "developer",
    "source": "stdlib_provider",
    "body": "Approaching context window cap.",
    "fired_at_turn": 4
  }
}

transcript_reminder_event({...}) is the lenient event builder for pipeline code that already has a normalized reminder-like dict. The stricter producer APIs below validate required fields and enum values.

Producing reminders

From a pipeline

Use transcript.inject_reminder(transcript, options) to append a pending reminder event to a transcript. It is a pure transform: the input transcript is unchanged and the returned transcript contains the new event.

let injected = transcript.inject_reminder(transcript(), {
  body: "Approaching context window cap.",
  tags: ["token_pressure"],
  dedupe_key: "token_pressure",
  ttl_turns: 3,
  preserve_on_compact: true,
  propagate: "session",
  role_hint: "developer",
})

let next_transcript = injected.transcript
log(injected.reminder_id)
log(injected.deduped_count)

The returned transcript has one additional system_reminder event and the same durable message list as the input transcript. body is required and must be non-empty. Optional tags, dedupe_key, ttl_turns, preserve_on_compact, propagate, and role_hint fields are validated; unknown option keys fail fast.

When dedupe_key is set, injection first removes any pending reminder events with the same key from the input transcript. The new reminder is then appended, and deduped_count reports how many older reminders were replaced. When an active EventLog is installed, replacement also emits a transcript.reminder.deduped record on transcript.reminder.lifecycle; every successful injection emits transcript.reminder.injected.

Use transcript.clear_reminders(transcript, selector) to remove pending reminders:

let cleared = transcript.clear_reminders(next_transcript, {
  tag: "token_pressure",
})
log(cleared.removed_count)

Selectors support id, tag, and dedupe_key. At least one selector is required. If multiple selectors are present, a reminder must match all of them to be removed. This builtin is also a pure transform and returns {transcript, removed_count}.

From a hook

Tool, persona, step, and session hooks can return reminder effects from events that support transcript mutation. A hook may return {reminder: {...}, then?: ...}, a bare reminder spec, or a session-level effect list.

register_tool_hook({
  pattern: "read_file",
  post: { ctx ->
    if ctx.result.truncated {
      return {
        reminder: {
          body: "The file read was truncated; inspect the specific range before editing.",
          tags: ["truncation"],
          dedupe_key: "read_file:truncated",
          ttl_turns: 1,
          propagate: "none",
        },
      }
    }
  },
})

Worker lifecycle events are observational and reject reminder effects with HARN-RMD-008. Move mutating reminders to a session, tool, step, or persona hook.

From a provider

agent_loop(...) evaluates canonical reminder providers by default. Register a custom provider with register_reminder_provider({id, subscribes_to, evaluate}). The evaluate closure receives {event, session, session_id, payload, options, config} and may return nil, a bare reminder spec, a {reminder: {...}} effect, or a list of effects.

register_reminder_provider({
  id: "workspace_guard",
  subscribes_to: ["session_idle"],
  evaluate: { ctx ->
    if ctx.config?.enabled == false {
      return nil
    }
    return {
      reminder: {
        body: "Workspace may have changed while idle; re-check touched files.",
        tags: ["workspace"],
        dedupe_key: "workspace:idled",
        ttl_turns: 1,
        propagate: "session",
      },
    }
  },
})

agent_loop(task, system, {
  reminders: {
    config: {
      workspace_guard: {enabled: true},
    },
  },
})

clear_reminder_providers() removes user-defined providers, mainly for tests. Canonical stdlib providers remain available through agent_loop unless disabled with reminder options.

From a host bridge

Bridge and ACP-style hosts inject ambient context with the session/remind JSON-RPC notification. This is distinct from session/inject, which is always user-role input.

{
  "jsonrpc": "2.0",
  "method": "session/remind",
  "params": {
    "body": "The workspace changed while you were idle; re-read src/lib.rs before editing.",
    "tags": ["workspace"],
    "dedupe_key": "workspace-change",
    "ttl_turns": 2,
    "role_hint": "system",
    "mode": "interrupt_immediate",
    "_meta": {
      "harn": {
        "origin": "file-watcher"
      }
    }
  }
}

mode accepts the same delivery values as queued user messages: interrupt_immediate, finish_step, and audit_only. The mode determines which agent-loop seam drains the reminder:

ModeDrained atBehavior
interrupt_immediateiteration_start, pre_tool_dispatch, post_tool_dispatch, iteration_end, daemon_idle_pre, daemon_idle_postWhen it arrives at pre_tool_dispatch (between LLM return and tool dispatch), the pending tool batch is skipped and the reminder lands in the next iteration's prompt. At every other seam, the reminder is appended to the transcript and visible on the next prompt build.
finish_stepiteration_start, post_tool_dispatch, iteration_endDrained at iteration boundaries only. Never short-circuits a tool batch. The model sees this reminder on the next prompt build — including the final iteration when the loop is about to terminate.
audit_onlyloop_exitDrained once the loop terminates, before finalize. The reminder lands in the transcript audit, but the model never sees it — no further LLM call runs after loop_exit. Use this for record-keeping and replay; use finish_step if the model must react to the reminder before the agent terminates (harn#2212).

See steering seams for the full seam catalog.

Host-specific extension fields belong under _meta. Invalid reminder payloads fail with HARN-RMD-002; unknown top-level reminder options fail with HARN-RMD-001.

Hosts can inspect the bridge queue with session/pending_injections. The response is {pendingCount, injections} in FIFO order and includes both pending user-message injects and pending reminders. Reminder rows carry the stable reminderId, mode, body, tags, dedupeKey, ttlTurns, roleHint, and source fields.

Hosts can revoke a queued reminder with session/revoke_reminder before a checkpoint drains it. The response returns status: "revoked" or "already_revoked" for idempotent repeats; races after delivery return a structured already_delivered error.

Canonical stdlib providers

Canonical providers are enabled by default inside agent_loop(...). Bare llm_call(...) renders pending reminders from the selected session but does not evaluate providers.

ProviderSubscribes toConfig keysReminder behavior
token_pressureon_budget_thresholdcontext_windowFires at about 70%, 85%, and 95% context use. Uses tag and dedupe key token_pressure, ttl_turns: 2, role_hint: "developer", propagate: "session", and sets preserve_on_compact: true at the critical threshold.
idle_nudgesession_idleidle_seconds or secondsFires when daemon idle wake interval reaches the threshold, defaulting to 60 seconds. Uses tag idle, dedupe key idle_nudge, ttl_turns: 1, and propagate: "none".
tool_output_truncatedpost_tool_usenoneFires when tool output was truncated or compacted before the model saw it. Uses tag truncation, dedupe key tool_output_truncated:<tool_name>, ttl_turns: 1, and propagate: "none".
post_compact_recappost_compactnoneFires after compaction archives messages. Uses tag recap, dedupe key post_compact_recap, ttl_turns: 2, and propagate: "session".
resume_continuityworker_resumednoneFires before the first resumed agent_loop turn. Uses tag and dedupe key resume_continuity, ttl_turns: 1, preserve_on_compact: true, and propagate: "none"; includes suspension reason, resume cause, optional resume input, and a pre-suspend digest for continue_transcript: false.
project_factssession_start, on_budget_thresholdnamespace, scope, root, max_facts, min_confidence, kind_filter, relevance_query, refresh_ratioRecalls typed harn.fact.v1 records via lexical (BM25) match against the session task and top-K by score (records that BM25 cannot score still surface, ranked by recency). Defaults: namespace project/facts, max_facts: 5, min_confidence: 0.5, query derived from the session task. Uses tag and dedupe key project_facts, ttl_turns: 1, and propagate: "session" so the same body refreshes on context-budget pressure and re-injects on the next session start. The on_budget_threshold refresh only fires once context usage crosses refresh_ratio (default 0.70) so per-turn recall stays bounded. Skipped silently when no schema-matched facts pass the filter.
workspace_anchorsession_start, on_budget_thresholdnoneRenders the active session workspace anchor and mounted roots when one is set. Uses tag and dedupe key workspace_anchor, ttl_turns: 1, and propagate: "session".
grounded_reviewpost_tool_use, post_step, post_agent_turnmax_findings, limit, text_scan, include_warningsFires only when payloads carry concrete verifier/runtime evidence: explicit tool runtime errors, non-accepted routing verifier signals, parse errors, undefined-name diagnostics, error-severity diagnostics, or failure lines from known verification commands such as cargo check, harn lint, pytest, go test, and tsc. Uses tags grounded_review and code_review, ttl_turns: 2, role_hint: "developer", and propagate: "none". Warnings and style nits are suppressed unless include_warnings: true; no LLM-only critique can fire the provider without a concrete signal.

Disable every provider with reminders: false or reminders: {enabled: false}. Disable selected providers by prefixing the provider id with -:

agent_loop(task, system, {
  reminders: {
    providers: ["-token_pressure", "-idle_nudge"],
    config: {
      token_pressure: {context_window: 128000},
      idle_nudge: {idle_seconds: 120},
    },
  },
})

The provider list has a hard diagnostic guard: enabling more than eight distinct providers raises HARN-RMD-007.

Capability-aware rendering

llm_call(...) loads pending reminders from the active session_id transcript, renders them after system_prompt_parts and the primary system prompt, and before system_appendix / system_suffix.

Rendering follows provider capabilities first, then role_hint:

Provider capability / reminder hintWire shape
prefers_role_developerSeparate role: "developer" message containing System reminder:\n.... This wins for all hints.
Anthropic wire format plus role_hint: "user_block"Prepended user content block containing <system-reminder>...</system-reminder>.
Anthropic wire format plus role_hint: "ephemeral_cache"Same user content block, with cache_control: {"type": "ephemeral"} when prompt caching is supported.
prefers_xml_scaffoldingSystem prompt text wrapped in <system-reminder> tags.
Fallback providersPlain system prompt text prefixed with System reminder:.

role_hint is a request, not a guarantee. Use provider-neutral "system" or "developer" unless the script is intentionally targeting an Anthropic-style user block. HARN-RMD-003 reports hardcoded role_hint: "user_block" with a provider route that cannot preserve that shape.

Compaction interaction

Reminders live in transcript events, not durable messages. Normal agent-session post-turn processing decrements finite ttl_turns. ttl_turns: 1 expires at the next post-turn boundary and emits an expiry lifecycle event when an EventLog is active.

transcript_compact(...) applies reminder lifecycle processing before it rebuilds the transcript:

  • finite TTLs decrement at the pre-compaction boundary
  • expired reminders are dropped
  • reminders sharing a dedupe_key collapse to the newest event
  • only preserve_on_compact: true reminders are copied into the compacted transcript
  • custom compactors receive surviving reminder payloads as their second argument
let compacted = transcript_compact(snapshot, {
  strategy: "custom",
  custom_compactor: { messages, reminders ->
    return transcript({
      messages: [
        {role: "system", content: "Summary plus " + str(len(reminders)) + " active reminders."},
      ],
    })
  },
})

Common gotcha: preserve_on_compact: false plus no finite ttl_turns creates a reminder that can live forever during normal turns but vanish at the next compaction. HARN-RMD-004 flags this shape. Add a finite TTL, or set preserve_on_compact: true when the reminder is meant to be durable across compaction.

Sub-agent propagation

When a parent agent hands work to a child session, Harn filters pending reminders into the handoff envelope's reminder_propagation field. The child seeds inherited reminders into its transcript before its first turn. Inherited copies rewrite source to "inherited" and set originating_agent_id to the session that first emitted the reminder.

propagateBehavior
"all"Inherit into direct children and allow inherited copies to continue into deeper descendants.
"session"Inherit into direct children from the originating session only; inherited copies are not re-forwarded.
"none"Keep local to the current session.

Use "all" sparingly for durable policy context. Use "session" for task-scoped context that direct helpers need. Use "none" for local observations such as truncated tool output or daemon idle nudges.

Diagnostic codes

The reminder lifecycle diagnostic family is HARN-RMD-NNN. Use harn explain HARN-RMD-005 or the diagnostic catalog for the current explanation and remediation.

CodeMeaning
HARN-RMD-001Unknown option key or invalid strict producer option shape.
HARN-RMD-002Invalid bridge reminder payload shape.
HARN-RMD-003user_block role hint is unsupported by the selected provider route.
HARN-RMD-004Discardable reminder has no finite TTL.
HARN-RMD-005Unknown propagate value.
HARN-RMD-006Reminder provider returned a malformed reminder spec.
HARN-RMD-007Too many reminder providers are enabled.
HARN-RMD-008Hook event does not support reminder effects.

EventLog events

Reminder producers and lifecycle boundaries emit audit records on the transcript.reminder.lifecycle topic when an EventLog is active. Each payload carries session_id, task_id, and agent_id so host and replay tooling can correlate reminder state with the active agent turn and tool call. Use event_log.subscribe(...) with kind_prefix: "transcript.reminder." to stream only reminder lifecycle events.

Event kindEmitted whenKey payload fields
transcript.reminder.injectedA new reminder enters the pending queue.reminder_id, tags, dedupe_key, source, role_hint, ttl_turns, propagate
transcript.reminder.firedA pending reminder is rendered into the next provider request.reminder_id, turn_number, rendered_role
transcript.reminder.dedupedA new reminder or compaction replaces older pending reminders with the same dedupe_key.replaced_id, replacing_id, dedupe_key
transcript.reminder.expiredA reminder is removed by TTL expiry, compaction, or an explicit clear operation.reminder_id, reason (ttl, compaction, or cleared)
transcript.reminder.droppedA malformed or unrenderable reminder cannot be used for the next provider request.reminder_id, reason (invalid or capability_mismatch)
transcript.reminder.inheritedA child session receives an inherited reminder.reminder_id, originating_agent_id, sub_agent_id, propagate
transcript.reminder.provider_evaluatedA registered provider evaluated against a hook event.provider_id, event, fired

Rendered reminders also surface to ACP clients as session/update notifications with sessionUpdate: "reminder_emitted" and _meta.harn.reminder metadata. Tool-call hook reminders attach a compact lifecycle report to ToolCallReceipt.audit.reminders when with_audit_log(...) is active. The receipt report identifies the hook event, tool call, reminder id, tags, dedupe key, source, role hint, TTL, and dedupe count without copying the reminder body into the receipt.

Cookbook

Token-pressure thresholds

Use the canonical provider and override only the context window when the route or harness cannot infer it.

agent_loop(task, system, {
  reminders: {
    config: {
      token_pressure: {context_window: 128000},
    },
  },
})

Project facts at session start

Persist project facts with store_fact so the canonical project_facts provider can recall them on the next session. Tune the namespace and filters per loop when the defaults are too broad.

store_fact({
  kind: "Decision",
  claim: "Build cancels in-flight tool calls on SIGINT before clearing state.",
  confidence: 0.92,
  evidence: [{kind: "FileRange", ref: "crates/harn-vm/src/cancel.rs:1"}],
})

agent_loop(task, system, {
  reminders: {
    config: {
      project_facts: {
        max_facts: 8,
        min_confidence: 0.6,
        kind_filter: ["decision", "constraint"],
      },
    },
  },
})

File-changed reminder from a hook

Use a dedupe key per file so repeated file-watcher events collapse into one pending nudge.

register_session_hook("file_edited", { event ->
  let path = to_string(event?.path ?? "")
  return {
    reminder: {
      body: "File changed externally: " + path + ". Re-read it before editing.",
      tags: ["workspace", "file_changed"],
      dedupe_key: "file_changed:" + path,
      ttl_turns: 2,
      propagate: "session",
    },
  }
})

Memory-derived reminder

Use propagate: "all" only when downstream agents need the same caution.

let memory_warning = "Customer prefers patch-sized PRs and explicit verification."
let injected = transcript.inject_reminder(transcript(), {
  body: memory_warning,
  tags: ["memory"],
  dedupe_key: "memory:customer-pr-style",
  ttl_turns: 4,
  preserve_on_compact: true,
  propagate: "all",
  role_hint: "developer",
})

Clear stale reminders after the condition resolves

Clear by dedupe_key for singleton reminders and by tag for groups.

let cleared = transcript.clear_reminders(current_transcript, {
  dedupe_key: "workspace-change",
})

Host-injected idle workspace nudge

Bridge hosts should use session/remind, not session/inject, when the content is ambient context rather than user text.

{
  "jsonrpc": "2.0",
  "method": "session/remind",
  "params": {
    "body": "Dependencies changed while the agent was idle; rerun the narrow test before continuing.",
    "tags": ["workspace", "deps"],
    "dedupe_key": "workspace:deps",
    "ttl_turns": 1,
    "propagate": "none",
    "mode": "finish_step"
  }
}

Provider-specific ephemeral cache hint

Use ephemeral_cache only when the route supports Anthropic-style user content blocks and prompt caching.

transcript.inject_reminder(transcript(), {
  body: "Large policy excerpt applies for this turn only.",
  tags: ["policy"],
  dedupe_key: "policy:turn",
  ttl_turns: 1,
  role_hint: "ephemeral_cache",
  propagate: "none",
})

Custom compactor reads reminders

Custom compactors receive the surviving reminder payloads after TTL and dedupe processing.

transcript_compact(snapshot, {
  strategy: "custom",
  custom_compactor: { messages, reminders ->
    for reminder in reminders {
      log(reminder.body)
    }
    return transcript({messages: messages})
  },
})

Cross-references