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.
utils/handlePromptSubmit.ts →
utils/processUserInput/processUserInput.ts →
utils/processUserInput/processTextPrompt.ts →
utils/messages.ts
At a high level the journey has four conceptual stages:
Submit & Route
handlePromptSubmit — validation, exit-word handling, queuing vs. immediate execution
Input Classification
processUserInput — image resize, slash/bash/text branching, hook execution
Message Construction
processTextPrompt + createUserMessage — building typed UserMessage objects
API Normalization
normalizeMessagesForAPI — flattening, deduplication, stripping virtual messages
The diagram below traces the exact code path from the React onSubmit handler to the first message pushed into the API request:
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.
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 })
}
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
AttachmentMessageobjects 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', ... }))
}
}
10,000 characters by applyTruncation().
Any output beyond that is replaced with a truncation notice, preventing runaway hook stdout
from inflating the context window.
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:
- Assign a fresh
promptIdviasetPromptId(randomUUID())and start the OpenTelemetry interaction span - Emit analytics for negative keyword detection (
matchesNegativeKeyword) and keep-going phrases - Assemble the content array — text first, then pasted image blocks below
- 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:
Stable Identity
randomUUID() or caller-supplied. Used for rewind, file history snapshots, and tool result pairing.
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.
Provenance
Tracks where the message came from — undefined = human keyboard, task-notification = scheduled task, etc.
Safety Snapshot
The permission mode at message creation time, stored so rewind can restore the exact safety level.
Tool Pairing
For tool_result messages, carries the structured output from the tool call.
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]'
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.
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:
- Reorder attachments — bubbles attachment messages up until they hit a tool result or assistant message boundary
- Strip virtual messages — display-only messages (
isVirtual: true) must never reach the API - 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
- Filter system/attachment messages — these are UI-only and are excluded from the API payload
- Merge consecutive same-role messages — the Anthropic API requires strict alternating user/assistant turns
- Ensure tool result pairing — every
tool_useblock must have a matchingtool_result; orphaned uses get theSYNTHETIC_TOOL_RESULT_PLACEHOLDER
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.
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:
Human input
Role "user". Carries content (string or block array), plus isMeta, origin, permissionMode, imagePasteIds, toolUseResult.
Model response
Role "assistant". Contains content blocks including text, tool_use, and thinking blocks. Carries full usage metrics.
Side-channel context
Not sent to the API directly. Carries IDE selection, hook outputs, agent mentions, memory headers. Reordered and merged before API dispatch.
UI-only signals
Subtypes: api_error, compact_boundary, turn_duration, informational. Filtered entirely from API payloads.
Streaming state
Ephemeral — live updates for tool execution progress. Never stored long-term; filtered before API calls.
Deletion marker
Marks a message as deleted from the conversation. Processed during compact/rewind operations.
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.
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
Promise.all but attachment loading is sequential within
getAttachmentMessages.
Key Takeaways
- Every submission normalizes through a single
QueuedCommandshape 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. UserPromptSubmithooks run after input classification but before the API call, giving external processes the ability to block, annotate, or stop any prompt.createUserMessageis the single factory for all user-role messages; itsisMetaflag is the mechanism that keeps system-generated context out of the visible transcript.normalizeMessagesForAPIis the final gatekeeper — it strips virtual messages, merges consecutive same-role turns, and ensures everytool_usehas a pairedtool_result.- Synthetic message constants (
INTERRUPT_MESSAGE,CANCEL_MESSAGE, etc.) double as training data guards — the HFI system rejects payloads containingSYNTHETIC_TOOL_RESULT_PLACEHOLDER.
Knowledge Check
handlePromptSubmit do when queryGuard.isActive is true and a user submits a normal prompt?preExpansionInput rather than the final expanded input?SYNTHETIC_TOOL_RESULT_PLACEHOLDER being exported from messages.ts?UserPromptSubmit hooks executed?isMeta: true on a UserMessage mean?