Wei Su
Decoding Grok Bot

Context Injection and Visibility

Where prompts get injected, and the four tiers of visibility

Two questions this answers:

  1. The prompts that constrain the model's behavior β€” which role do they actually live in?
  2. If they're in the user role, why don't they show up in the chat UI?

The core finding

In this architecture, "what the model sees" and "what the user sees" are fully orthogonal, carried by two independent data models.

TranscriptPrompt messages
What it isThe persisted truth of the conversation, and the UI's only render sourceA message array built fresh per request, discarded after
Consumed byThe renderer process (React)The model (proxied through Cursor's backend)
Structureturns[], each turn = userMessage + steps[]A standard role/content message list
Produced byUser input plus the model's steps landing on diskBuilt from the transcript, then layered with injections

Every <system_reminder> injection happens only on the second model. None of them are written to the transcript, so the UI isn't "filtering them out at render time" β€” they never enter the UI's data source at all.

A second direct finding: this guidance is in neither the system nor the developer role. The message model has only user / assistant / system; there is no concept of a developer role anywhere. The SendMessage behavioral constraints ride the user role.


Three layers of prompt injection

The SendMessage constraints aren't "write it into the system prompt once and done." They escalate through three layers, each addressing a different failure mode.

Layer 1: the tool definition (shipped with the tools array)

A standard Zod tool schema, where the .describe() text on the type enum tells the model what each bubble is for:

var SEND_MESSAGE_TYPES = ["text", "attachment", "widget", "cursor-agent", "secret-request"];

"text for chat messages, attachment for actual files or standalone media, widget for an interactive question with selectable options, cursor-agent to reference a Cursor cloud agent by its bcId, secret-request to ask the user for a credential through a secure masked input (never a chat paste)."

The tool name constant: SAND_SEND_MESSAGE_TOOL_NAME = "SendMessage".

Layer 2: a <system_reminder> appended to every user turn

This is where SEND_MESSAGE_GUIDANCE lands. The code path:

SEND_MESSAGE_GUIDANCE
  β†’ formatProjectRootBody()        // only concatenated when sendMessageEnabled === true
  β†’ formatProjectPrompt(kind)      // kind: "initial" | "reminder"
  β†’ wrapped in <system_reminder> tags
  β†’ joinUserTurnSystemReminders()  // joined with other reminders by \n\n
  β†’ userContent.push({ type: "text", text: ... })   // ← appended to the user message body

The details that matter:

  • Two variants, initial and reminder. The Project kickoff turn gets the full version; every subsequent turn re-injects the reminder version. The constraint isn't stated once β€” it's refreshed each turn.
  • Re-injected after compaction. formatProjectCompactionPrompt() ensures the constraint survives when the context gets summarized away.
  • Conditional. When sendMessageEnabled is false none of it is injected β€” the same switch that governs whether the tool is exposed at all (see the dual-mode section below).
  • Other reminders flow through the same pipeline: mode reminders (SwitchModeReminderSnippet) and the anti-AskQuestion reminder (processAntiAskQuestionSystemReminder, gated by the enableAntiAskQuestionSysReminder flag).

Layer 3: runtime middleware injection (the important one)

src/host/runner/send-message-reminder-middleware.ts. This layer isn't a static prompt β€” it inspects the model's behavior before each request and injects a reminder on the spot when the behavior falls short.

var DEFAULT_SEND_MESSAGE_REMINDER_THRESHOLD = 6;
var DEFAULT_EARLY_RESULT_REMINDER_THRESHOLD = 0;

var SendMessageReminderMiddleware = class extends BaseMiddleware {
  stream(ctx, invocationId, tools, options) {
    const messages = this.innerExecutor.getMessages();
    const lastMessage = messages.at(-1);
    // last message was already a reminder β€” don't stack another
    if (lastMessage !== undefined && isSendMessageReminderMessage(lastMessage)) {
      return this.innerExecutor.stream(ctx, invocationId, tools, options);
    }

    const toolCallsSinceLastSend = countToolCallsSinceLastSendMessage(messages);

    if (toolCallsSinceLastSend > this.threshold) {
      // silent too long β†’ push for a progress update
      this.innerExecutor.appendMessages(createSendMessageReminderMessage());
    } else if (
      toolCallsSinceLastSend > this.earlyResultThreshold &&
      hasSendMessageSinceRealTurnStart(messages) &&
      !hasReminderFiredThisSilentStreak(messages)
    ) {
      // already reported once this turn, now back to work β†’ nudge to send results early
      this.innerExecutor.appendMessages(createEarlyResultReminderMessage());
    }

    return this.innerExecutor.stream(ctx, invocationId, tools, options);
  }
};

Note that appendMessages targets the executor (the inference message list), not the transcript store. That is the technical reason none of this is visible.

The two reminder texts

Silence reminder (fires after six consecutive non-SendMessage tool calls):

<system_reminder> You have made several tool calls without a SendMessage, so the user is currently watching silence. Actually invoke the SendMessage tool now β€” make a real tool/function call, not text you write. Plain assistant text is NEVER shown to the user; only a real SendMessage tool invocation reaches them, so if you don't call the tool they just keep seeing silence. Send a brief, specific update on what you are doing or what you just found before continuing. </system_reminder>

Early-result reminder (once per silent streak, after the turn has already produced at least one message):

<system_reminder> Remember: the user cannot see tool output or your thinking β€” only SendMessage reaches them. If you have produced a result or finished what they asked, send it now with a SendMessage tool call before continuing or ending the turn. If you are still mid-task, keep working and send the result once you have it. </system_reminder>

The same middleware family also carries DISK_PRESSURE_REMINDER_MESSAGE (warning the agent off heavy I/O when the cloud sandbox is running out of disk), which shows this is a general "runtime state β†’ prompt reminder" mechanism, not something specific to messaging.

What the wording reveals

"make a real tool/function call, not text you write" reads unmistakably as a patch. The model writes text that looks like it's addressing the user, that text is never displayed, and the user just sits there watching a spinner. Which means:

Declaring "your text is invisible" in a prompt does not stop the model from violating it. Runtime detection is what actually catches it.

That is the single most valuable engineering lesson in this architecture.


Why it has to borrow the user role

If the content is a system instruction, why not use the system role?

Because most model APIs don't allow inserting a system message mid-conversation β€” system only goes at the front. And the entire value of these reminders is being inserted at the most recent position: during a long-running task the context fills with dozens of tool calls, and the attention weight on that opening constraint decays continuously. A reminder only works if it sits right next to the moment the model is about to make a decision.

So it borrows the user role as a shell, and uses a <system_reminder> XML tag to signal "this isn't the user talking, it's a system note." This is the common approach across agent products including Claude Code and Cursor.

Self-marking and deduplication

Injected messages carry a provider-level marker so they can be identified in history later:

function createSendMessageReminderMessage() {
  return {
    role: "user",
    content: SEND_MESSAGE_REMINDER_MESSAGE,
    providerOptions: { cursor: { sandSendMessageReminder: true } }
  };
}

isInjectedReminderMessage() checks both the providerOptions.cursor.* markers and the content string β€” belt and braces, in case a serialization round-trip drops the marker. That identification is mandatory, because several checks are only correct if they skip injected messages:

  • hasSendMessageSinceRealTurnStart() β€” determines whether anything has been reported since the turn genuinely began. It must continue past injected messages, or it mistakes a system reminder for a user utterance and miscomputes the turn boundary.
  • hasReminderFiredThisSilentStreak() β€” keeps reminders from carpet-bombing the same silent stretch.
  • countToolCallsSinceLastSendMessage() β€” walks backward and stops counting at a real user/system message or a SendMessage call.

Incidentally, the providerOptions field name gives away that the underlying layer is the Vercel AI SDK, with a custom cursor provider.


Four tiers of visibility

Put the mechanisms together and the architecture offers four independent visibility tiers:

MechanismIn transcriptUser seesModel seesPurpose
Middleware-injected reminderβŒβŒβœ… (this request only)One-shot behavioral correction, discarded after
[SAND_HIDDEN_PROMPT] prefixβœ…βŒβœ… (persistent)Instructions the system issues on the user's behalf
assistantMessage (plain text)βœ…βŒ (expandable in the activity stream)βœ…The model's internal thinking
SendMessage tool callβœ…βœ…βœ…The only user-visible channel

The hidden prefix

var SAND_HIDDEN_PROMPT_MARKER = "[SAND_HIDDEN_PROMPT]";

The inverse of middleware injection: this content is written to the transcript (so the model can see it, it persists, and it stays in effect across turns), but the renderer flags it so the UI doesn't display it:

const hidden = rawUserText.startsWith(SAND_HIDDEN_PROMPT_MARKER);
const userText = stripHiddenMarker(rawUserText);
if (userText.trim().length > 0) {
  items.push({ kind: "user", id: ..., text: userText, ...(hidden ? { hidden: true } : {}) });
}

It exists for instructions the system issues on the user's behalf β€” scheduled triggers, background tasks waking the agent, external event drivers. They need to stay in history for the model to understand context, but shouldn't appear in the chat as something the user said.

The render path

The UI derives display items from the transcript, entirely independent of prompt messages:

function stepToOutlineItem(step, id) {
  switch (step.message.case) {
    case "assistantMessage": return { kind: "assistant-text", id, text };   // thinking, not a bubble
    case "thinkingMessage":  return { kind: "thinking", id, text, durationMs };
    case "toolCall": {
      if (toolCall.tool.case === "sendMessageToolCall") {
        return { kind: "send-message", id, message };   // ← the only branch that becomes a chat bubble
      }
      return { kind: "tool-call", id, name, status, summary };
    }
  }
}

Dual mode: the constraint itself is switchable

An easy thing to miss: all of the above is off by default.

function isProjectSendMessageEnabled(state) {
  return state.isRootProjectConversation === true;
}

SendMessage is only enabled in the root conversation of a Project. Two communication paradigms coexist in the app:

Ordinary chatProject conversation
sendMessageEnabledfalsetrue
Model textis the reply, streamed directlydemoted to thinking, not displayed
SendMessage toolnot exposedthe only visible channel
Guidance injectionnoneevery turn, plus dynamic middleware reminders
Interaction shapequestion and answermulti-bubble, silent work, proactive reporting

Why split it this way:

  • Synchronous conversation: the user is waiting for a reply, so streaming text directly is the best experience. Adding a tool would only add a round trip per message.
  • Asynchronous long tasks: the agent runs autonomously for tens of minutes and produces a lot of intermediate reasoning; showing all of it would flood the user. Inverting visibility β€” invisible by default, visible only through an explicit tool call β€” makes silence the default and turns reporting into a deliberate act.

One more hierarchical detail: the condition is isRootProjectConversation, so only the root conversation has the tool. Subagents and side chats within a Project don't; their output flows back to the parent agent instead. It's the organizational equivalent of "only the account lead may contact the client directly."


The full data flow

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  Transcript (persisted truth)β”‚
                    β”‚  turns[] = userMessage+steps β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β”‚           β”‚
        build prompt         β”‚           β”‚  UI render
                             β–Ό           β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚  rootPromptExecutor        β”‚   β”‚ deriveOutlineTurns…()     β”‚
    β”‚  β”œ system prompt           β”‚   β”‚ stepToOutlineItem()       β”‚
    β”‚  β”‚ (systemPromptAssembly)  β”‚   β”‚                           β”‚
    β”‚  β”œ history turns           β”‚   β”‚ sendMessageToolCall       β”‚
    β”‚  β”” userContent             β”‚   β”‚      β†’ chat bubble βœ…     β”‚
    β”‚     + <system_reminder>    β”‚   β”‚ assistantMessage          β”‚
    β”‚       (guidance, each turn)β”‚   β”‚      β†’ folded in activity β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ toolCall                  β”‚
             β”‚                       β”‚      β†’ activity entry     β”‚
             β”‚                       β”‚ [SAND_HIDDEN_PROMPT]      β”‚
             β–Ό                       β”‚      β†’ hidden: true       β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
    β”‚ SendMessageReminder-       β”‚
    β”‚ Middleware.stream()        β”‚    ⚠ middleware-injected reminders
    β”‚  counts toolCalls since    β”‚      never appear on this side
    β”‚  last send; over threshold β”‚
    β”‚  β†’ appendMessages()        β”‚
    β”‚    (role: "user")          β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β–Ό
      sent to Cursor's backend for inference
      (ConnectRPC / StreamUnifiedChatWithTools)

What's worth borrowing

  1. Split the transcript and the prompt messages into two models. This is the foundation of every visibility control here. Once the UI renders the same array you send to the model, every "hidden injection" has to be patched over with fragile filter rules β€” and the more you inject, the more you miss.

  2. Mid-conversation injection can only borrow the user role, so self-label with an XML tag. Also give injected messages a provider-level marker, because turn-boundary detection and deduplication all depend on being able to tell which messages are injected.

  3. Static prompts don't constrain behavior; runtime detection does. That line β€” "make a real tool/function call, not text you write" β€” is a scar. Even after declaring the text invisible, the model kept writing text for the user. Counting, thresholds, and on-the-spot reminders are the part that actually works.

  4. Re-inject constraints after compaction. Long-task context will be summarized. A key constraint stated only once at the start disappears with it.

  5. Inverted visibility is the key design for long-running agents. "Visible by default" suits conversational products; "silent by default, report explicitly" suits autonomous agents. One product can switch per conversation type rather than picking one.

  6. Deduplicate and throttle the reminders themselves. Guards like hasReminderFiredThisSilentStreak and "skip if the last message was already a reminder" keep the reminder traffic from polluting the context and stealing attention.


Based on static analysis of the shipped build. Code and text quotations are taken from the v0.16.0 and v0.18.0 artifacts; internals may change between versions.

On this page