Context Injection and Visibility
Where prompts get injected, and the four tiers of visibility
Two questions this answers:
- The prompts that constrain the model's behavior β which role do they actually live in?
- If they're in the
userrole, 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.
| Transcript | Prompt messages | |
|---|---|---|
| What it is | The persisted truth of the conversation, and the UI's only render source | A message array built fresh per request, discarded after |
| Consumed by | The renderer process (React) | The model (proxied through Cursor's backend) |
| Structure | turns[], each turn = userMessage + steps[] | A standard role/content message list |
| Produced by | User input plus the model's steps landing on disk | Built 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 bodyThe 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
sendMessageEnabledis 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 theenableAntiAskQuestionSysReminderflag).
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 mustcontinuepast 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:
| Mechanism | In transcript | User sees | Model sees | Purpose |
|---|---|---|---|---|
| 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 chat | Project conversation | |
|---|---|---|
sendMessageEnabled | false | true |
| Model text | is the reply, streamed directly | demoted to thinking, not displayed |
| SendMessage tool | not exposed | the only visible channel |
| Guidance injection | none | every turn, plus dynamic middleware reminders |
| Interaction shape | question and answer | multi-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
-
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.
-
Mid-conversation injection can only borrow the
userrole, 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. -
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.
-
Re-inject constraints after compaction. Long-task context will be summarized. A key constraint stated only once at the start disappears with it.
-
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.
-
Deduplicate and throttle the reminders themselves. Guards like
hasReminderFiredThisSilentStreakand "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.