Wei Su
Decoding Grok Bot

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:

  1. 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.
  2. Every user-visible bubble is one SendMessage tool call β€” several messages means several calls, so they are separate by construction.
  3. Bubble type is decided by the tool's type enum β€” 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 SendMessage the 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

typeRequired fieldClient renderingTypical use
textcontentOrdinary text bubble (react-markdown + KaTeX + Mermaid + highlight.js)Progress updates, conclusions
attachmenturl (file:// or https://)File card (icon, filename, size, download button)PDFs, Markdown, spreadsheets the agent produced
widgetoption listInteractive question controlWhen the user has to decide something
cursor-agentbcIdCursor cloud agent card, opens in Cursor on clickCross-product handoff
secret-requestβ€”Masked secure inputAPI 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:

  1. Resolve the local/sandbox path and read the file.
  2. Ingest it through attachments-service into a per-agent attachment directory (<agentDir>/attachments), content-addressed by SHA-256.
  3. 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 carry content.
  • type: "attachment" must carry a url with a valid scheme (the validation message reads "https:// scheme when type is attachment").
  • Any type outside 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 (as user role), 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

ApproachHow it worksWeaknessGrok Bot's choice
System prompt conventionThe model interleaves separators or markers in one reply; the frontend splits with regexAny format drift breaks rendering; files can only exist as link text; interactive controls are impossibleNot used
One structured payloadThe model emits a single large JSON containing every bubbleCan't stream bubble by bubble; one mid-flight error voids everything; "report while working" is impossibleNot used
Message as tool callOne tool call per message, schema-validated, state-machine gatedCosts 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

LayerTechnology
Desktop shellElectron 42 (Chromium 148), Squirrel auto-update
RendererReact 19 + TypeScript, Vite, StyleX, Base UI, no router (state-driven conditional rendering), in-house store + useSyncExternalStore, TanStack Query
ComposerTiptap 3 (@mention, #reference, :emoji, PR/workflow reference chips)
Content renderingreact-markdown + KaTeX + Mermaid + highlight.js; pdfjs-dist / mammoth / xlsx for client-side document parsing
TransportConnectRPC + Protobuf (@bufbuild/protobuf), WebSocket
Local storagebetter-sqlite3, per-agent data directories, SHA-256 content-addressed attachments
Process architectureelectron-main / preload (incl. webview and VNC variants) / host process / agent isolation workers / search index worker / local-exec-daemon / node-agent-coordinator
ObservabilitySentry (crashes), Statsig (experiments and flags), OpenTelemetry (tracing)
Monorepopnpm workspace, many @anysphere/* internal packages reusing Cursor's agent infrastructure, with MCP support

What's worth borrowing

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. Files go through the filesystem as references. The message carries only a file:// or https:// 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.

On this page