The Message Mechanism
The SendMessage tool and multi-bubble rendering
The questions this answers: why can the AI send several separate bubbles? Why are files always their own card, never mixed into the text? Is that a system prompt constraint, or something else?
The short version
Grok Bot doesn't constrain the model's text format through a system prompt. It turns "send a message to the user" into a tool call, named SendMessage.
Three design decisions follow from that:
- The model's ordinary text output is never shown to the user β it is treated as internal thinking and either discarded or recorded internally only.
- Every user-visible bubble is one
SendMessagetool call β several messages means several calls, so they are separate by construction. - Bubble type is decided by the tool's
typeenum β text, file, interactive control, and credential request are each their own type. The renderer branches on type, which makes "a file bleeding into the text" structurally impossible.
The design is isomorphic to the Slack Bot API: the chat UI is an output device for the model, driven by a structured API rather than by parsing conventions out of free-form text.
Direct evidence from the build artifacts
The tool guidance
SEND_MESSAGE_GUIDANCE, defined in src/host/runner/tools/send-message-tool.ts (path recovered from the sourcemap):
## Communicating with the user
The `SendMessage` tool is your only user-visible communication channel.
Regular assistant text is treated as internal thinking and is not shown
to the user.
Use `SendMessage` for:
- meaningful progress updates;
- questions or blockers requiring user input when the Ask Question tool
is not appropriate;
- the final result of your work.
After a progress message, continue working normally. After sending your
final message, stop without repeating it. A successful tool result means
the message was delivered.Worth noting:
- The first sentence declares
SendMessagethe only user-visible channel. - It gives explicit pacing: progress update β keep working β final result β stop. This is what produces the observed rhythm of "Alright, let me go look into thatβ¦" (first call) β tool execution β "Research doneβ¦" (second call).
- The guidance is conditionally injected: it is only appended when
options.sendMessageEnabled === true, meaning tool exposure and the prompt text share a single switch.
The parameter schema (Zod)
var SEND_MESSAGE_TYPES = [
"text",
"attachment",
"widget",
"cursor-agent",
"secret-request"
];
var sendMessageObjectSchema = z.object({
type: z.enum(SEND_MESSAGE_TYPES).describe(SEND_MESSAGE_TYPE_DESCRIPTION),
content: z.string().trim().optional()
.describe("Required when type is text. The message to show to the user."),
url: z.string().trim().optional()
.describe("Required when type is attachment. Use file:// for local files
or https:// for remote files and standalone media."),
images: z.array(z.object({
url: z.string().trim().min(1)
.describe("file:// or https:// URL of the image."),
alt: z.string().trim().optional()
.describe("Optional short description of this image, shown on hover
and as its fullscreen caption.")
})).optional()
// ...
});
var sendMessageParameters = sendMessageObjectSchema.superRefine(refineSendMessage);The type field's description (SEND_MESSAGE_TYPE_DESCRIPTION) reads:
"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 (renders as a card that opens the agent in Cursor on click), secret-request to ask the user for a credential through a secure masked input (never a chat paste)."
The state gate
SAND_AWAITING_USER_SEND_MESSAGE_BLOCKED =
"This turn is already waiting on the user (you sent a question widget or
handed the box back to them), so this message was not delivered. Wait for
the user β their response arrives as the next message β then say this on
your next turn."If the model has already sent a widget (and is waiting on a choice) or handed sandbox control back to the user, a further SendMessage call is rejected at runtime, returning the text above so the model can correct itself. This is a pure code-level session state machine, independent of the prompt.
Five bubble types
type | Required field | Client rendering | Typical use |
|---|---|---|---|
text | content | Ordinary text bubble (react-markdown + KaTeX + Mermaid + highlight.js) | Progress updates, conclusions |
attachment | url (file:// or https://) | File card (icon, filename, size, download button) | PDFs, Markdown, spreadsheets the agent produced |
widget | option list | Interactive question control | When the user has to decide something |
cursor-agent | bcId | Cursor cloud agent card, opens in Cursor on click | Cross-product handoff |
secret-request | β | Masked secure input | API keys and passwords, explicitly never pasted into chat |
There is also a separate images array: one call can carry several images (url plus optional alt), rendered as a grid, with the alt text used for hover tooltips and the fullscreen caption.
How an attachment lands
When type: "attachment" and url is a file:// path (a file the agent generated inside the cloud sandbox), the host process runs resolveAttachmentSource:
- Resolve the local/sandbox path and read the file.
- Ingest it through attachments-service into a per-agent attachment directory (
<agentDir>/attachments), content-addressed by SHA-256. - Emit a transcript entry; the renderer produces a card by attachment type and supports local preview β pdfjs-dist for PDF, mammoth for Word, xlsx for Excel, all parsed client-side.
In other words, the file itself moves through the filesystem while the chat message carries only a reference. This is the same infrastructure as the upload path, run in reverse (uploads go renderer β IPC β main-process staging β gRPC StageFiles/SyncFile chunked sync into the cloud sandbox).
Three layers of control
The reliability of this mechanism comes from three layers working together, not from any single one.
Layer 1: the protocol (hard constraint)
A Zod schema plus superRefine cross-validation enforces structure at the tool call boundary:
type: "text"must carrycontent.type: "attachment"must carry aurlwith a valid scheme (the validation message reads "https:// scheme when type is attachment").- Any
typeoutside the enum is rejected outright β the code has an exhaustive check.
Validation failure returns an error, and the model retries on its next step. Bad output bounces at the protocol layer and never reaches the UI.
Layer 2: the prompt (soft guidance)
SEND_MESSAGE_GUIDANCE teaches the model:
- when to send (progress, blockers, results);
- the pacing (keep working after a progress message, stop after the final one, don't repeat);
- the semantic split (files go through attachment, questions prefer the dedicated Ask Question tool).
So the prompt does participate, but what it governs is when to send and what to say β not how to keep files and text apart. The schema handles that.
Note: this guidance does not live in the system prompt. It is wrapped in a
<system_reminder>tag and appended to the end of every user turn (asuserrole), and a runtime middleware injects additional reminders once the model has made six consecutive non-SendMessage tool calls. For the full mechanism β injection point, role choice, and why none of it shows up in the UI β see Context Injection and Visibility.
Layer 3: the runtime state machine and behavioral correction
The host process tracks session state (awaiting user vs. working). Untimely sends are intercepted in code and returned as a readable error that tells the model to wait for the next turn.
There is a backstop in the other direction too. SendMessageReminderMiddleware counts how many other tool calls have happened since the last SendMessage, and once it crosses the threshold (default 6) it injects a reminder pushing the model to report progress. The phrasing in that reminder β "make a real tool/function call, not text you write" β reads as a patch: declaring "your text is invisible" in a prompt is not enough, because the model still regularly writes text meant for the user. Only runtime detection catches it.
Feature flags control presentation separately. glass_project_send_message_bubbles governs card-style bubbles in Project conversations, and its comment is explicit: "Presentation only β tool exposure and transcript normalization stay on when this is off."
The data flow
ββββββββββββββββββββββββ Cloud sandbox ("box") βββββββββββββββββββββββββ
β Agent (the Grok model) β
β ββ plain text output βββββββββΊ treated as thinking, not shown β
β ββ SendMessage{type:"text", content:"..."} ββ β
β ββ SendMessage{type:"attachment", url:"file://β¦"} ββ€ one call β
β ββ SendMessage{type:"widget", options:[...]} ββ = one event β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β ConnectRPC / Protobuf stream
β (send_message_tool_pb)
ββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β Desktop host process β
β ββ Zod schema validation (superRefine) β errors back to model β
β ββ state machine check (awaiting user?) β block if untimely β
β ββ attachment: resolveAttachmentSource β ingest β sha256 CAS β
β ββ write transcript (one SendMessage = one transcript entry) β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β IPC / store sync
ββββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββ
β Renderer process (React 19 SPA) β
β branches on the transcript entry's type: β
β text β Markdown bubble attachment β file card (pdfjs/xlsx) β
β widget β choice control images β grid secret-request β mask β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββThe key point: the renderer consumes a stream of structured transcript entries. At no point does anything parse free-form model text to pull a file out of it.
Compared to the alternatives
| Approach | How it works | Weakness | Grok Bot's choice |
|---|---|---|---|
| System prompt convention | The model interleaves separators or markers in one reply; the frontend splits with regex | Any format drift breaks rendering; files can only exist as link text; interactive controls are impossible | Not used |
| One structured payload | The model emits a single large JSON containing every bubble | Can't stream bubble by bubble; one mid-flight error voids everything; "report while working" is impossible | Not used |
| Message as tool call | One tool call per message, schema-validated, state-machine gated | Costs extra turns and tool call quota | β Adopted, in exchange for type safety, streaming, interactivity, and retryable errors |
For a long-running desktop agent, the cost of extra tool calls is close to irrelevant, and the payoff is real:
- Natural multi-bubble pacing β progress bubbles slot in mid-work, interleaved with tool execution.
- Type-safe files and controls β the UI can't break because the model's formatting drifted.
- A closed interaction loop β widget answers and secret-request credentials come back to the agent as structured data, and combine with the state machine into proper turn-taking.
- Clear security boundaries β credentials go through a masked input rather than plaintext chat, and attachments go through the filesystem rather than being embedded in the prompt.
Background: the app's stack
| Layer | Technology |
|---|---|
| Desktop shell | Electron 42 (Chromium 148), Squirrel auto-update |
| Renderer | React 19 + TypeScript, Vite, StyleX, Base UI, no router (state-driven conditional rendering), in-house store + useSyncExternalStore, TanStack Query |
| Composer | Tiptap 3 (@mention, #reference, :emoji, PR/workflow reference chips) |
| Content rendering | react-markdown + KaTeX + Mermaid + highlight.js; pdfjs-dist / mammoth / xlsx for client-side document parsing |
| Transport | ConnectRPC + Protobuf (@bufbuild/protobuf), WebSocket |
| Local storage | better-sqlite3, per-agent data directories, SHA-256 content-addressed attachments |
| Process architecture | electron-main / preload (incl. webview and VNC variants) / host process / agent isolation workers / search index worker / local-exec-daemon / node-agent-coordinator |
| Observability | Sentry (crashes), Statsig (experiments and flags), OpenTelemetry (tracing) |
| Monorepo | pnpm workspace, many @anysphere/* internal packages reusing Cursor's agent infrastructure, with MCP support |
What's worth borrowing
- Demote user-visible output to a tool. Declare that ordinary text is thinking and invisible, forcing the model to explicitly choose what to send and in what shape. One prompt sentence plus one tool definition buys you a fully structured UI.
- Treat the schema as the UI contract. A bubble type enum plus per-type required fields turns "a file bleeding into the text" from hoping the model behaves into structurally impossible.
- Make validation failures recoverable. Write error messages for the model β natural language, with the fix spelled out β so it repairs itself without the user ever noticing.
- Put session timing in the runtime. Rules like "don't send while waiting on the user" are far more reliable enforced in code than stated in a prompt.
- Files go through the filesystem as references. The message carries only a
file://orhttps://reference; the bytes move through content-addressed storage, and previews are parsed client-side, so nothing consumes model context.
Based on static analysis of the shipped build. Quotations are taken from the v0.16.0 artifacts; internals may change between versions.