Skip to content

22. Claude Percept Skill: Nonce Tags, Consumer Hints, and Bidirectional Memory

Date: 2026-05-20

Status

Accepted — partially supersedes ADR-0016; partially superseded by ADR-0031 (local agent write path, dynamic consumer hint names)

Context

ADR-0016 established that "Claude only reads" memory — relevant Percepts are injected at turn start via --append-system-prompt, and the write path belongs exclusively to the local agent via record_memory. In practice this created an asymmetry: Claude would produce important facts during coding and analysis turns that the system had no way to capture without the user or local agent manually calling record_memory after the fact.

A naive write path — giving Claude direct tool access to record_memory — creates two problems:

  1. False captures during explanation. Claude frequently explains its own output format, writes documentation, or quotes instructions. If the write tag is a static string like <milk:percept>, Claude's explanation of how to use the system would trigger an actual record.

  2. Context compaction. Claude Code compresses context during long sessions. A system prompt injected only at session start may be silently dropped. If the memory instruction is lost, Claude can no longer emit facts for the rest of the session.

Decision

Nonce-tagged percept emission. Claude is instructed via the MemoryInstruction fragment (in the static --append-system-prompt-file) to emit facts using session-specific tags:

<milk:percept:NONCE>fact</milk:percept:NONCE>

The nonce is a 6-character alphanumeric string generated once per milk session by claude.GenerateNonce() and persisted as sess.EscalationNonce. It is stable across all turns of a session. Because the nonce is unknown ahead of time, it cannot appear in pre-written explanations or code examples — only live responses contain the correct nonce. The stable nonce also allows the MemoryInstruction block to be cached by Claude across turns (see ADR-0004).

Stream interception via perceptWriter. When StreamOpts.OnPercept and PerceptNonce are set, the out io.Writer passed to Stream is wrapped in a perceptWriter. This FSM-based writer buffers bytes until it recognises a complete <milk:percept:NONCE>…</milk:percept:NONCE> sequence, strips the tag from the display output, and calls OnPercept(content, consumerHint). Tags may span multiple Write calls; partial tag bytes are buffered and flushed at stream end. An unclosed open tag (stream ended before the matching close tag) is silently discarded.

Consumer hints. The tag body may be prefixed with @<primaryName>: or @<escalationName>: to restrict which agent receives the percept at injection time. The actual names are the configured agents[].name values injected into MemoryInstruction at runtime. consumerHintFrom / ConsumerHintFrom accept the names as a variadic so they match whatever names are in use. ConsumerLocal percepts are filtered out when building the escalation agent's [Remembered facts] block; ConsumerEscalation percepts are filtered out of the local agent's context. See ADR-0031 for the full dynamic-name design.

Re-injection on threshold. The MemoryInstruction fragment is part of the static context file (see ADR-0004). On ContextModeResume turns the static file is omitted entirely — Claude already has it cached. Re-injection is triggered only by shouldInjectMemoryInstructions when the turn or byte threshold is crossed (MemoryReinjectionTurns / MemoryReinjectionBytes, default: 20 turns or 40 KB), guarding against Claude-side context compaction dropping the instruction.

BuildContext percept injection. BuildContext(sess, nonce, percepts []string, resuming bool, primaryName, escalationName string) accepts an optional list of content strings. When non-empty, they are rendered as a [Remembered facts] block appended after the MemoryInstruction. This is the read path for the escalation agent: top-k Percepts filtered by ConsumerEscalation or ConsumerAll are passed in at turn start.

Consequences

Claude can now contribute to the shared memory store as a first-class writer. Facts recorded by Claude arrive via ProducerClaude and start at W = 0.7, subject to the same decay/promote cycle as local-agent Percepts (ADR-0020). The nonce mechanism prevents spurious captures and keeps the write path auditable: every Claude-recorded Percept has ProducerClaude attribution.

The static <milk:percept> tag (no nonce) is kept as a legacy fallback for the zero-nonce code path but is never used in production; production code always generates a nonce via GenerateNonce().

perceptWriter and stripPerceptTags handle arbitrary chunk boundaries; this is exercised by TestPerceptWriter_SplitAcrossWrites and related tests in internal/agent/claude/stream_test.go.