markdown.engineering
Lesson 38

The Message Processing Pipeline

From raw keystrokes to API-bound content blocks — every transformation your input undergoes before Claude sees it.

01 Overview

Every prompt you type travels through a carefully layered pipeline before it reaches the Anthropic API. The pipeline handles images, slash commands, bash mode, queuing, hooks, attachments, and multi-block normalization — all in a single async waterfall that spans three source files and roughly 1,500 lines of TypeScript.

Source files covered
utils/handlePromptSubmit.tsutils/processUserInput/processUserInput.tsutils/processUserInput/processTextPrompt.tsutils/messages.ts

At a high level the journey has four conceptual stages:

Stage 1

Submit & Route

handlePromptSubmit — validation, exit-word handling, queuing vs. immediate execution

Stage 2

Input Classification

processUserInput — image resize, slash/bash/text branching, hook execution

Stage 3

Message Construction

processTextPrompt + createUserMessage — building typed UserMessage objects

Stage 4

API Normalization

normalizeMessagesForAPI — flattening, deduplication, stripping virtual messages

02 End-to-End Pipeline Diagram

The diagram below traces the exact code path from the React onSubmit handler to the first message pushed into the API request:

flowchart TD A["User presses Enter\nonSubmit in REPL component"] --> B["handlePromptSubmit()\nhandlePromptSubmit.ts"] B --> C{"queuedCommands\nalready set?"} C -->|"Yes (queue processor)"| EXEC["executeUserInput()\nskip validation"] C -->|"No (direct user input)"| D["Trim input\nFilter orphaned image refs\nexpandPastedTextRefs()"] D --> E{"Exit word?\nexit / quit / :q"} E -->|"Yes"| EXIT["Re-submit as /exit\nor gracefulShutdownSync(0)"] E -->|"No"| F{"Starts with /\nand !skipSlashCommands?"} F -->|"immediate cmd"| IMM["Execute immediately\n(e.g. /config, /doctor)\nsetToolJSX JSX render"] F -->|"no"| G{"queryGuard.isActive\nor externalLoading?"} G -->|"Yes — busy"| QUEUE["enqueue()\ncommandQueue push\nnotifySubscribers()"] G -->|"No — idle"| CMD["Wrap as QueuedCommand\nstartQueryProfile()"] CMD --> EXEC EXEC --> WL["runWithWorkload()\nAsyncLocalStorage workload tag"] WL --> PUI["processUserInput()\nfor each QueuedCommand"] PUI --> HOOKS1["executeUserPromptSubmitHooks()\ncheck blocking / additional contexts"] HOOKS1 --> BASE["processUserInputBase()"] BASE --> IMG["Image blocks:\nmaybeResizeAndDownsampleImageBlock()\nstoreImages()"] IMG --> BRIDGE{"bridgeOrigin &\nstarts with /?"} BRIDGE -->|"Yes"| BSAFE{"isBridgeSafeCommand?"} BSAFE -->|"Yes"| CLEARSKIP["effectiveSkipSlash = false"] BSAFE -->|"No"| BRIDGEMSG["Return error UserMessage\n'not available over Remote Control'"] BRIDGE -->|"No"| ULTRA{"ULTRAPLAN\nfeature flag?"} CLEARSKIP --> ULTRA ULTRA -->|"keyword match"| ULP["processSlashCommand('/ultraplan ...')"] ULTRA -->|"no"| ATTACH["getAttachmentMessages()\nIDE selection, todos, diffs"] ATTACH --> MODE{"mode?"} MODE -->|"bash"| BASH["processBashCommand()\nwrap in BashTool.call()"] MODE -->|"starts with /"| SLASH["processSlashCommand()\nfork subagent or local-jsx"] MODE -->|"prompt"| TEXT["processTextPrompt()"] TEXT --> PROMPT_ID["setPromptId(randomUUID())\nstartInteractionSpan()"] PROMPT_ID --> KW["matchesNegativeKeyword()\nmatchesKeepGoingKeyword()\nlogEvent('tengu_input_prompt')"] KW --> UM["createUserMessage()\n{ type:'user', uuid, timestamp, content }"] UM --> RES["Return { messages[], shouldQuery:true }"] RES --> NORM["normalizeMessagesForAPI()\nflatten multi-block, strip virtuals\nensure tool_result pairing"] NORM --> API["Anthropic SDK call\napi.ts → onQuery()"] style A fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style API fill:#1a3a1a,stroke:#6e9468,color:#b8b0a4 style QUEUE fill:#2a2a1a,stroke:#b8965e,color:#b8b0a4 style IMM fill:#2a1a3a,stroke:#8e82ad,color:#b8b0a4 style EXIT fill:#2a1a1a,stroke:#c47a50,color:#b8b0a4
03 Stage 1 — handlePromptSubmit

handlePromptSubmit.ts is the React-facing entry point. It receives the raw string from the text input alongside every piece of UI context: the current message list, abort controllers, IDE selection state, queued commands, and more.

The Two Execution Paths

There are two distinct ways input reaches executeUserInput:

// Path A — queue processor (commands already validated)
if (queuedCommands?.length) {
  startQueryProfile()
  await executeUserInput({ queuedCommands, ... })
  return
}

// Path B — direct user input
const finalInput = expandPastedTextRefs(input, pastedContents)
// ... validation, queuing checks ...
const cmd: QueuedCommand = { value: finalInput, mode, pastedContents, ... }
await executeUserInput({ queuedCommands: [cmd], ... })

The queue processor path deliberately skips all validation — those commands were validated when they were originally enqueued. Direct input goes through reference expansion, exit word checking, immediate command detection, and the queryGuard busy-check.

Paste Reference Expansion

When you paste a file or text into the input, Claude stores it under a numeric ID and inserts a [Pasted text #N] token as a placeholder. Before any further processing, expandPastedTextRefs replaces those tokens with the actual content. Images get similar treatment but are only included if their [Image #N] pill is still present in the text — orphaned image entries are filtered out early:

const referencedIds = new Set(parseReferences(input).map(r => r.id))
const pastedContents = Object.fromEntries(
  Object.entries(rawPastedContents).filter(
    ([, c]) => c.type !== 'image' || referencedIds.has(c.id),
  ),
)

The Queue Guard

If queryGuard.isActive is true (a query is running), new input is placed in the module-level commandQueue rather than executed immediately. This is a plain array that React components read via useSyncExternalStore, making the queue reactive without any additional Redux/Zustand machinery.

Concurrency detail
The queue guard is reserved before processUserInput starts, not after. This prevents a race where a second submit arrives during the await inside processBashCommand or getMessagesForSlashCommand — both of which suspend before the query is "officially" running.

Immediate Commands

Commands flagged immediate: true in the command registry (like /config and /doctor) can run while a query is in progress. They are detected by a startsWith('/') check and dispatched via setToolJSX — rendering directly into the Ink component tree rather than being sent to the API:

const immediateCommand = commands.find(
  cmd => cmd.immediate && isCommandEnabled(cmd) && cmd.name === commandName
)
if (immediateCommand && immediateCommand.type === 'local-jsx') {
  const impl = await immediateCommand.load()
  const jsx = await impl.call(onDone, context, commandArgs)
  setToolJSX({ jsx, isLocalJSXCommand: true, isImmediate: true })
}
04 Stage 2 — processUserInput

processUserInput.ts is the main classification engine. It receives a QueuedCommand's value and routes it to one of four handlers based on mode and content shape.

Image Pre-Processing

Array-form input (used when the SDK or VS Code sends multimodal content) is normalized first. Every image block is passed through maybeResizeAndDownsampleImageBlock before the string is extracted:

for (const block of input) {
  if (block.type === 'image') {
    const resized = await maybeResizeAndDownsampleImageBlock(block)
    processedBlocks.push(resized.block)
  } else {
    processedBlocks.push(block)
  }
}
normalizedInput = processedBlocks

Pasted images (from the UI clipboard path) are resized in parallel to avoid serializing on slow images:

const imageProcessingResults = await Promise.all(
  imageContents.map(async pastedImage => {
    const resized = await maybeResizeAndDownsampleImageBlock(imageBlock)
    return { resized, originalDimensions, sourcePath }
  })
)

The Branching Switch

After images are normalized and attachments are loaded, a three-way branch sends input to the correct processor:

// 1. Bash mode
if (mode === 'bash') {
  return processBashCommand(inputString, ...)
}

// 2. Slash command (not from bridge/CCR)
if (!effectiveSkipSlash && inputString.startsWith('/')) {
  return processSlashCommand(inputString, ...)
}

// 3. Regular text prompt (default)
return processTextPrompt(normalizedInput, imageContentBlocks, ...)

The Ultraplan Keyword Path

There is a hidden fourth branch: if the ULTRAPLAN feature flag is on and the input (before paste expansion) contains the ultraplan keyword, it is silently rewritten to /ultraplan <input> and routed through the slash command path. The detection runs against the pre-expansion input specifically to prevent pasted content from accidentally triggering it.

Bridge / Remote Control Safety

Messages from iOS or web clients arrive with skipSlashCommands: true to prevent accidental slash command execution. The bridgeOrigin flag creates a nuanced override: if the command is flagged isBridgeSafeCommand(), the skip is cleared and the command executes normally. If it is a known-but-unsafe command (local-jsx or terminal-only), a helpful error is returned immediately rather than letting raw text like /config confuse the model.

UserPromptSubmit Hooks

After processUserInputBase returns with shouldQuery: true, registered UserPromptSubmit hooks are executed. A hook can:

  • Block the prompt entirely — returning a system warning message instead
  • Prevent continuation — keeping the prompt in context but stopping the query
  • Inject additional context — appending AttachmentMessage objects before the API call
for await (const hookResult of executeUserPromptSubmitHooks(...)) {
  if (hookResult.blockingError) {
    return { messages: [createSystemMessage(blockingMessage)], shouldQuery: false }
  }
  if (hookResult.preventContinuation) {
    result.messages.push(createUserMessage({ content: 'Operation stopped by hook' }))
    result.shouldQuery = false
    return result
  }
  if (hookResult.additionalContexts) {
    result.messages.push(createAttachmentMessage({ type: 'hook_additional_context', ... }))
  }
}
Hook output safety
Hook output is capped at 10,000 characters by applyTruncation(). Any output beyond that is replaced with a truncation notice, preventing runaway hook stdout from inflating the context window.
05 Stage 3 — Message Construction

The final "normal prompt" path lands in processTextPrompt.ts, which is where a raw string becomes a typed UserMessage that the rest of the system understands.

processTextPrompt — What It Does

This function is intentionally small — only ~100 lines. Its job is to:

  1. Assign a fresh promptId via setPromptId(randomUUID()) and start the OpenTelemetry interaction span
  2. Emit analytics for negative keyword detection (matchesNegativeKeyword) and keep-going phrases
  3. Assemble the content array — text first, then pasted image blocks below
  4. Call createUserMessage() and return { messages, shouldQuery: true }
// Pasted images: text first, images after
if (imageContentBlocks.length > 0) {
  const textContent = typeof input === 'string'
    ? [{ type: 'text', text: input }]
    : input
  const userMessage = createUserMessage({
    content: [...textContent, ...imageContentBlocks],
    uuid, imagePasteIds, permissionMode,
  })
  return { messages: [userMessage, ...attachmentMessages], shouldQuery: true }
}

createUserMessage — The Message Factory

createUserMessage in utils/messages.ts is the canonical factory for all user-side messages. Every field it stamps on the object carries meaning:

uuid

Stable Identity

randomUUID() or caller-supplied. Used for rewind, file history snapshots, and tool result pairing.

isMeta

Hidden from UI

When true, the message is model-visible but never shown in the transcript. Used for image metadata, scheduled tasks, and system-generated prompts.

origin

Provenance

Tracks where the message came from — undefined = human keyboard, task-notification = scheduled task, etc.

permissionMode

Safety Snapshot

The permission mode at message creation time, stored so rewind can restore the exact safety level.

toolUseResult

Tool Pairing

For tool_result messages, carries the structured output from the tool call.

imagePasteIds

Image Tracking

Ordered list of paste IDs for images in the content array — used when splitting messages in normalization.

Image Metadata isMeta Messages

Whenever images are processed, a separate isMeta user message is appended containing dimension and source path information. This gives the model spatial context (e.g., "[Image source: /tmp/screenshot.png, 1920x1080]") without polluting the visible transcript:

function addImageMetadataMessage(result, imageMetadataTexts) {
  if (imageMetadataTexts.length > 0) {
    result.messages.push(
      createUserMessage({
        content: imageMetadataTexts.map(text => ({ type: 'text', text })),
        isMeta: true,
      })
    )
  }
  return result
}

Synthetic Messages

utils/messages.ts exports a set of constant strings used to inject synthetic signals into the conversation. These are never shown directly to users but influence model behavior at critical moments:

export const INTERRUPT_MESSAGE = '[Request interrupted by user]'
export const CANCEL_MESSAGE =
  "The user doesn't want to take this action right now. STOP what you are doing..."
export const REJECT_MESSAGE =
  "The user doesn't want to proceed with this tool use..."
export const SYNTHETIC_TOOL_RESULT_PLACEHOLDER =
  '[Tool result missing due to internal error]'
Training data protection
SYNTHETIC_TOOL_RESULT_PLACEHOLDER is also exported specifically so the HFI (human feedback interface) submission path can detect it and reject any payload containing it. Fake tool results would poison training data if submitted — this is an explicit safety valve.
06 Stage 4 — normalizeMessagesForAPI

Before the final list of messages reaches api.ts, it passes through normalizeMessagesForAPI — a multi-pass transformation that ensures the payload is valid for the Anthropic API.

What normalizeMessages Does First

normalizeMessages (distinct from the API variant) splits every multi-content-block message into individual single-block messages. It uses a deterministic UUID derivation scheme so that the same input always produces the same output UUIDs:

// Derive stable UUID from parent UUID + block index
export function deriveUUID(parentUUID: UUID, index: number): UUID {
  const hex = index.toString(16).padStart(12, '0')
  return `${parentUUID.slice(0, 24)}${hex}` as UUID
}

normalizeMessagesForAPI Passes

The API normalization function performs several distinct passes:

  1. Reorder attachments — bubbles attachment messages up until they hit a tool result or assistant message boundary
  2. Strip virtual messages — display-only messages (isVirtual: true) must never reach the API
  3. Build error-to-strip map — maps API error types (PDF too large, image too large, request too large) to the block types they should strip from the preceding message
  4. Filter system/attachment messages — these are UI-only and are excluded from the API payload
  5. Merge consecutive same-role messages — the Anthropic API requires strict alternating user/assistant turns
  6. Ensure tool result pairing — every tool_use block must have a matching tool_result; orphaned uses get the SYNTHETIC_TOOL_RESULT_PLACEHOLDER
Why consecutive merging matters
Because hooks, attachments, and user messages can all add user-role content in a single pipeline run, it is entirely possible to end up with two consecutive user messages. The merge pass collapses them into a single user message with concatenated content blocks — satisfying the API's strict alternating-turn requirement.
Deep dive — The short message ID scheme

Claude Code uses 6-character base36 "short IDs" derived from full UUIDs for the snip tool referencing system. These IDs are injected into API-bound messages as [id:abc123] tags so the model can reference specific past messages by a short token rather than the full 36-character UUID.

export function deriveShortMessageId(uuid: string): string {
  // First 10 hex chars of UUID (dashes removed)
  const hex = uuid.replace(/-/g, '').slice(0, 10)
  // Convert to base36, take 6 chars
  return parseInt(hex, 16).toString(36).slice(0, 6)
}

The derivation is deterministic: the same UUID always yields the same short ID. This is critical — the system injects these tags on every API call, so if the derivation were random it would produce different tags each turn, breaking any model references to prior messages.

07 Message Taxonomy

The internal message type system has significantly more variants than just "user" and "assistant". Understanding the full taxonomy explains why the pipeline needs so many transformation passes:

UserMessage

Human input

Role "user". Carries content (string or block array), plus isMeta, origin, permissionMode, imagePasteIds, toolUseResult.

AssistantMessage

Model response

Role "assistant". Contains content blocks including text, tool_use, and thinking blocks. Carries full usage metrics.

AttachmentMessage

Side-channel context

Not sent to the API directly. Carries IDE selection, hook outputs, agent mentions, memory headers. Reordered and merged before API dispatch.

SystemMessage

UI-only signals

Subtypes: api_error, compact_boundary, turn_duration, informational. Filtered entirely from API payloads.

ProgressMessage

Streaming state

Ephemeral — live updates for tool execution progress. Never stored long-term; filtered before API calls.

TombstoneMessage

Deletion marker

Marks a message as deleted from the conversation. Processed during compact/rewind operations.

08 The Memory Correction Hint Pattern

One subtle but instructive example of how the pipeline injects model guidance is the memory correction hint. When auto-memory is enabled and the GrowthBook feature gate tengu_amber_prism is active, rejection and cancellation messages get a postscript appended:

const MEMORY_CORRECTION_HINT =
  "\n\nNote: The user's next message may contain a correction or preference. "
  + "Pay close attention — if they explain what went wrong or how they'd "
  + "prefer you to work, consider saving that to memory for future sessions."

export function withMemoryCorrectionHint(message: string): string {
  if (isAutoMemoryEnabled() && getFeatureValue_CACHED('tengu_amber_prism', false)) {
    return message + MEMORY_CORRECTION_HINT
  }
  return message
}

This pattern demonstrates how the pipeline uses message content itself as a mechanism to steer model behavior — no special API field needed. The hint only reaches the model via the normal text content of a tool result block.

09 Query Profiling Checkpoints

Scattered throughout the pipeline are calls to queryCheckpoint(label). These are performance breadcrumbs that record wall-clock timestamps at key moments. The labels give an exact map of where time is spent:

query_process_user_input_start
query_process_user_input_base_start
query_image_processing_start / _end
query_pasted_image_processing_start / _end
query_attachment_loading_start / _end
query_hooks_start / _end
query_process_user_input_base_end
query_file_history_snapshot_start / _end
query_process_user_input_end
Performance insight
The checkpoint names reveal the three biggest potential latency sources: image processing (resize + base64 encode), attachment loading (IDE selection + todo list + git diff), and hook execution (external process calls). All three run async but not all are parallelized — pasted images use Promise.all but attachment loading is sequential within getAttachmentMessages.

Key Takeaways

  • Every submission normalizes through a single QueuedCommand shape so both the direct and queue-processor paths share identical execution logic.
  • The pipeline has four distinct mode branches: bash, slash command, ultraplan keyword (hidden), and plain text prompt.
  • Images are resized twice — once during inline-block normalization and again when processing pasted UI clipboard content — both times in parallel via Promise.all.
  • UserPromptSubmit hooks run after input classification but before the API call, giving external processes the ability to block, annotate, or stop any prompt.
  • createUserMessage is the single factory for all user-role messages; its isMeta flag is the mechanism that keeps system-generated context out of the visible transcript.
  • normalizeMessagesForAPI is the final gatekeeper — it strips virtual messages, merges consecutive same-role turns, and ensures every tool_use has a paired tool_result.
  • Synthetic message constants (INTERRUPT_MESSAGE, CANCEL_MESSAGE, etc.) double as training data guards — the HFI system rejects payloads containing SYNTHETIC_TOOL_RESULT_PLACEHOLDER.

Knowledge Check

1. What does handlePromptSubmit do when queryGuard.isActive is true and a user submits a normal prompt?
2. Why does the ultraplan keyword detection run against preExpansionInput rather than the final expanded input?
3. What is the purpose of SYNTHETIC_TOOL_RESULT_PLACEHOLDER being exported from messages.ts?
4. At what point in the pipeline are UserPromptSubmit hooks executed?
5. What does isMeta: true on a UserMessage mean?
0/5