RFD 094: Built-in tell_user Tool for Mid-Turn User-Addressed Messages
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-07-02
- Extends: RFD 058
Summary
Add a built-in tell_user tool that lets the assistant deliver a message the user must see, mid-turn, without ending the agentic loop. The message renders as regular chat content; the assistant receives a short acknowledgement and continues working.
The tool itself is small. Most of this RFD activates generic mechanisms that RFD 058 defines but does not yet act on: honoring MCP audience annotations at JP's two view boundaries, rendering text/markdown content through the markdown pipeline, and one new style knob (style.header). tell_user is the first consumer of these mechanisms, not a special case in jp-core.
Motivation
A response containing only chat text is terminal: the turn ends. A response containing tool calls keeps the agentic loop going — JP executes the tools and sends the responses back. This couples "say something to the user" with "have unrelated tool work in the same response": the assistant cannot deliver a progress update, a partial deliverable, or a direct answer to a mid-loop question and then continue working, unless it happens to also call another tool.
tell_user removes that coupling. The message rides in a tool call, so the response is non-terminal; the acknowledgement keeps the loop alive. This matches the send_to_user pattern Anthropic documents for long-running agents (Prompting Claude Fable), including its observation that tool inputs are delivered verbatim while free text may be treated as summarizable narration.
Secondary benefit: the message carries an explicit "addressed to the user" marker, so every consumer of the conversation — terminal, web viewer, future frontends — can identify user-facing deliverables without heuristics.
Design
User-visible behavior
The assistant calls tell_user like any other tool:
{
"name": "tell_user",
"arguments": {
"message": "Phase 1 done: all 14 call sites migrated. Starting on the test suite next; this will take a few more minutes.",
},
}The user sees the message rendered as markdown chat content — no "Calling tool" header, no arguments block, no result chrome. The assistant receives "Message shown to user." as the tool result and the loop continues. Turn-end semantics are unchanged: a text-only response remains terminal, and tell_user keeps the loop alive exactly like any other tool call.
Tool response shape
tell_user returns two content blocks in the RFD 058 format:
{
"content": [
{
"type": "text",
"text": "<the message, verbatim>",
"mimeType": "text/markdown",
"annotations": { "audience": ["user"] },
},
{
"type": "text",
"text": "Message shown to user.",
"annotations": { "audience": ["assistant"] },
},
],
}Two notes on this shape:
audienceis MCP's standard annotation (user/assistant, per block). RFD 058 carries the annotation types for MCP compatibility but does not act on them; this RFD adds the first behavior.mimeTypeontextblocks is the optional field RFD 058 defines as a JP extension (MCP'sTextContenthas no mimeType), following RFD 065's MCP-compatible superset principle: MCP tools never set it, and absence means plain text. Alternatives covers why the message does not ride in aresourceblock, which hasmimeTypenatively.
Audience honoring
The two view boundaries apply a symmetric filter:
- Provider view (RFD 058's shared block-to-string conversion): includes blocks whose
audienceis absent or containsassistant. TheToolCallResponsesent back to the provider carries only the acknowledgement — the message is not duplicated in the tool result. The assistant-authoredToolCallRequestretains themessageargument in the replayed transcript, as with every tool call. - User view (chat-style terminal rendering, the plugin host's
userevents view): includes blocks whoseaudienceis absent or containsuser. The acknowledgement is not part of the user view.
If the provider filter removes every block from a successful result, JP substitutes a neutral placeholder — "Tool executed successfully." — so the request/response pairing stays wire-valid. The placeholder does not hint at withheld content; telling the model that hidden content exists invites retries and speculation. A failed result keeps isError and gets "Tool failed." in the same case.
This also fixes a latent spec-compliance gap: MCP servers can send audience annotations today, and JP silently ships user-only blocks to the LLM. After this RFD, JP honors the annotation for every tool, not just tell_user.
The user view as a canonical API
"Which content is addressed to the user" must have exactly one definition, or every consumer improvises it (and a chat-only view silently drops tell_user messages). jp_conversation exposes a single accessor — the user view — that yields user-addressed content regardless of the carrying event: ChatResponse content and user-audience blocks from tool call responses. Chat-style consumers use it directly — the plugin host's ReadEvents handler when a plugin requests the user view, and any chat-only rendering. The terminal's tool-call rendering reads the same audience data through inline_results' per-audience settings.
The layering is strict: the user view is computed from event data alone — audience annotations — and knows nothing of configuration. Display policy (style.hidden, style.header, inline_results) belongs to the renderers. Terminal style does not propagate to plugins.
ReadEvents gains a view selector: raw (the default) returns the full serialized event stream unchanged; user returns the user view. Both shapes have real consumers — chat-style frontends want the projection, audit and tooling paths want every block — so the choice is explicit in the request rather than implied by the consumer. serve-web requests the user view for its chat rendering.
The user view is the raw shape, filtered: the same event kinds, IDs, and response envelope, no synthetic types (consistent with the rejection of event synthesis in Alternatives). The filtering rules are defined per event family; this RFD defines the chat, turn, and tool-call families:
turn_startandchat_requestevents pass through unchanged — a chat-style consumer needs the user's own messages and turn boundaries, not only assistant-side content.chat_responseevents pass filtered by variant:MessageandStructuredpass (both are the assistant's answer, addressed to the user);Reasoningis dropped — thinking content is not user-addressed, and its terminal display is already an opt-in style choice. Consumers that want reasoning use therawview.tool_call_responseevents pass with their content reduced to user-audience blocks; responses left empty by the reduction are dropped, along with their pairedtool_call_request.tool_call_requestevents are dropped (arguments are not user-addressed content).- Inquiry events are deferred: not every persisted inquiry pair is user-facing — inquiries resolved by the inquiry backend record exchanges the user never saw — and the source attribution that distinguishes them belongs to RFD 082. Inquiry-family rules land with that work.
- All remaining event kinds are dropped.
raw is the only view that preserves conversation-stream invariants: in the user view a tool_call_response may have no paired request, so the result is a projection in event shape, not a valid conversation stream. Consumers must not feed it back into anything that consumes streams — provider conversion, storage repair, compaction, or event validators.
// request
{ "type": "read_events", "conversation": "<id>", "view": "user" }
// response: the same EventsResponse envelope as `raw`, filtered
{ "data": [
{ "type": "turn_start", /* … */ },
{ "type": "chat_request", "content": "…" },
{ "type": "tool_call_response", "id": "call_1", "content": [
{ "type": "text", "text": "Phase 1 done.", "mimeType": "text/markdown",
"annotations": { "audience": ["user"] } }
] },
{ "type": "chat_response", "message": "…" }
] }The ubiquitous language gains two entries: Audience and User View.
Markdown rendering for content blocks
Blocks carrying mimeType: text/markdown render through the jp_md pipeline — the same rendering chat content receives — instead of a fenced code block. This is generic: any tool returning a report, summary, or explanation as markdown benefits. It makes the block renderer a second consumer of jp_md's public rendering API; the existing streaming-identity and comrak cross-validation suites become shared contract tests for both consumers.
style.header
A new knob on the per-tool display style: whether to render the "Calling tool <name>" header line. Accepts a boolean or "on" / "off", following the bool-or-string pattern inline_results and results_file_link already use (their string vocabularies differ; header introduces "on"). Defaults to true. Setting it off also suppresses the streaming temp line shown while the tool's arguments are being received. The style.error overlay cannot override header: the header renders before the result exists, so an error-conditional header is structurally meaningless. With parameters = "<command>", setting header = "off" prints the formatter's output with no header line above it.
style.hidden is unchanged and remains the absolute kill switch: hidden = true renders nothing for the tool in the terminal, including user-audience blocks. A user who hides a tool has opted out of its terminal output entirely; the raw stream and plugin views are unaffected.
Result display and audience
No new style knob is needed: inline_results remains the single control for what a tool call's results display inline. When RFD 058's block model lands, the value gains a per-audience map form; the existing scalars stay and apply to both audiences:
[conversation.tools.my_tool.style.inline_results]
user = "full" # blocks addressed to the user: show fully
assistant = "off" # blocks addressed to the assistant: show nothingEach audience key takes the values the scalar form takes today — off, full, or a line count — plus one map-only value: chat. chat renders the selected blocks as assistant speech through the chat pipeline, untruncated. It is valid only on the user key; assistant = "chat" is rejected. An omitted key keeps the default (10). A scalar value (off, full, <N>) is shorthand for setting both audiences to that value — off is off for everything, exactly as hidden suppresses everything. The pre-existing serialized forms all remain valid — booleans, off / full strings, numbers, and the { truncate = { lines = N } } object — and are distinguished from the audience map by their keys.
A block with no audience annotation is addressed to both audiences and renders under the more permissive of the two settings. Permissiveness is a total order: off < a line count < full, the larger of two line counts wins, and 0 is accepted as a line count equivalent to off. chat counts as full in this comparison, but the speech classification applies only to blocks explicitly annotated user: an unannotated block admitted by a chat setting renders as ordinary result display, in full.
user | assistant | unannotated block renders |
|---|---|---|
off | off | hidden |
10 | off | first 10 lines |
5 | 10 | first 10 lines |
full | 10 | full |
chat | off | full, as result display |
| omitted | off | first 10 lines (default) |
Presentation is mimeType-driven wherever a block renders — a text/markdown block is pretty-printed through the markdown pipeline inside result display just as it is inside chat speech. Channels follow RFD 048: the terminal renders chat-classified blocks on stdout, like any other assistant speech; all other tool-call rendering — headers, arguments, and result display, including user-addressed non-chat blocks — stays on stderr. Display styles govern terminal text rendering only; structured output modes (--format json) serialize events and are unchanged by this RFD. serve-web renders chat-classified blocks through its existing chat path.
Today's behavior is preserved everywhere: no existing tool emits audience annotations, and the scalar default (10) applies to all blocks exactly as now. The style.error overlay composes unchanged.
Tool configuration
Registered in jp_cli::cmd::query::tool::builtins::all(), using the existing describe_tools registration path, with the if_named enable policy RFD 083 proposes for ask_user (083 is expected to merge before this RFD):
source: builtin.enable:{ state = true, allow_toggle = "if_named" }(per RFD 081) — enabled by default, immune to bare-T, disableable by name. The reasoning matchesask_user: this is a core conversational capability, the only in-band way for the assistant to surface a deliverable mid-turn.run: Unattended,result: Unattended— displaying a message needs no permission or delivery prompt.Style:
toml[conversation.tools.tell_user.style] header = "off" parameters = "off" inline_results = { user = "chat", assistant = "off" } results_file_link = "off"The message (user-addressed) renders as assistant speech; the acknowledgement (assistant-addressed) is hidden. Failures stay visible without an error overlay: error text is unannotated — addressed to both audiences — so the more-permissive rule renders it in full, as result display rather than speech.
parametersschema: a single requiredmessagestring.description(model-visible; guards against overuse). The positive cases follow Anthropic's publishedsend_to_userguidance (Prompting Claude Fable); the final-answer guard is JP-specific, because in JP a text-only response is the proper terminal channel:Display a message directly to the user. Use this for progress updates with specific numbers, partial deliverables, or a direct reply to a question the user asked mid-task — content the user must see exactly as written before the task finishes. The message is rendered verbatim as chat content; you receive an acknowledgement and your turn continues. Do not route narration or internal reasoning through this tool, and do not use it for your final response — end your turn with a normal message for that.
Defining the tool is not sufficient on its own: Anthropic documents that Claude Fable 5 rarely calls send_to_user without an instruction in the system prompt (Prompting Claude Fable). JP does not ship default elicitation text — that is persona and workspace configuration — but the recommended snippet, adapted from Anthropic's guidance, is:
Between tool calls, when you have content the user must read verbatim (a partial deliverable, a direct answer to their question), call the
tell_usertool with that content. Usetell_useronly for user-facing content, not for narration or reasoning.
The tool description carries the usage contract either way.
Local-tool equivalence
Nothing in this design requires jp-core treatment. The same tool is expressible as a local tool — a script emitting the content JSON above — plus the TOML style block. The builtin is packaging, chosen for out-of-the-box availability, no external binary dependency, and no per-message process spawn. The builtin's module comment states this; when a tool registry lands (the direction sketched in RFD 072's plugin registry), distribution of non-privileged bundled tools can move there.
This surfaces a distinction JP does not draw today: built-ins that are merely bundled (tell_user — generic mechanisms, shipped for convenience) versus built-ins that are privileged (describe_tools — reads tool metadata no external tool can access). Formalizing that taxonomy is future work, out of scope here.
Drawbacks
- One more built-in with overuse potential. An assistant that narrates every step through
tell_userdegrades the experience. The description discourages this; real usage should be monitored. - The message is stored twice on disk (request arguments and response block). It is never duplicated on the wire: the provider sees it once, in the replayed tool-call arguments the assistant authored; the tool result carries only the acknowledgement.
- Audience-split responses can diverge. A tool may show the user one thing and the assistant another; both are right and neither knows. The raw stream persists every block with its annotation, the raw
ReadEventsview andinline_results = "full"expose the divergence on demand, and the documented convention is that audience-split content must be additive or reformulated, never contradictory. Fortell_userthe assistant-facing content is a boilerplate ack, so the risk here is nil; the convention exists for future adopters. - Consumers must adopt the user view. A consumer that pattern-matches raw event kinds misses user-addressed tool content. Post-RFD 058 every consumer must become block-aware regardless; the canonical accessor makes the correct behavior the easy path.
Alternatives
Strip the tool call and persist a ChatResponse instead
Rejected. The acknowledgement is the loop-continuation mechanism itself: without a tool result, the next request has nothing legal to send — the alternatives are fabricating a user message or relying on assistant-prefill continuation, which does not compose with tool use across providers. Providers also validate the replayed shape: Anthropic thinking signatures and Google thought signatures are tied to the exact event structure, and JP already carries recovery machinery for when they drift. Rewriting a tool call into text at the position providers validate manufactures that failure mode. Finally, the swap erases the record's semantics: a mid-loop note and a final answer become indistinguishable.
Persist the pair plus a display-only ChatResponse event
Rejected. Materializing the view duplicates content for every consumer (grep, compaction, export) and requires a provider-invisibility marker that fails silently when a code path forgets it. The audience filter computes the same view with nothing to keep in sync.
Event-synthesis user-projection
Swap tell_user pairs into synthetic ChatResponse events inside a projection applied at view boundaries. Rejected: its one advantage — consumers stay unchanged — is void once RFD 058 changes the serialized ToolCallResponse shape, which forces every consumer to become block-aware anyway. Audience filtering achieves the result without synthesizing events.
Carry the message in a resource block
resource blocks have mimeType natively, which avoids the text-block extension. Rejected: a resource is identified content — the URI is required and load-bearing. Resource identity feeds URI canonicalization, checksums, and the deduplication work built on RFD 058 (RFD 066, RFD 067); a tell_user message has no identity, so every message would carry a fabricated URI and drag ephemeral prose into machinery designed for files. The optional mimeType field on text blocks (RFD 058) states the actual semantics — unidentified text with a presentation hint — in the same pattern as the JP extension fields RFD 065 already defines on Resource.
A typed ToolCallResponse.user_message field
Rejected. A JP-only parallel mechanism for something the block model expresses natively once audience annotations are honored — two mechanisms for one job.
Custom argument formatter
Render the message via style.parameters = "<command>", the fs_modify_file pattern. Rejected: formatter output renders verbatim (never through the markdown pipeline), the mechanism is terminal-only by contract, and a builtin whose default rendering shells out to an external command is a portability wart.
A ParametersStyle::Markdown variant
Rejected: a documented, user-facing config value with a single plausible consumer. Superseded by mimeType-driven block rendering, which puts presentation on the response, where the content lives.
Ship as a local tool instead of a builtin
Viable by construction — see Local-tool equivalence. The builtin packaging wins on availability until a tool registry provides distribution for bundled tools.
Non-Goals
- Cross-frontend rendering of custom argument formatters. Custom formatter output remains terminal-only; changing that is a separate RFD.
- Notification routing. Queueing, deduplicating, or escalating user-addressed messages is RFD 011 territory.
- Acting on other MCP annotations.
priorityandlastModifiedremain carried-but-inert. - The bundled-vs-privileged builtin taxonomy. Named above; deserves its own decision RFD alongside the registry work.
- Default elicitation language. Persona and workspace configuration own when the assistant is encouraged to call
tell_user.
Risks and Open Questions
- RFD 058 is in Discussion. This RFD tracks its content block model, including the optional text-block
mimeTypefield defined there. - Live/replay equivalence. The live path renders blocks at outcome time; terminal replay renders from raw events, applying the same display policy (
hidden,header,inline_results) per turn. The user view plays no part in terminal replay; it serves chat-style consumers. One required appearance, two code paths — pinned by an equivalence test comparing live terminal output against terminal replay, in the spirit of the streaming-identity suite. - Elicitation. Models may under-call the tool without system-prompt encouragement. This is a utilization gap, not a correctness one: the tool description (always model-visible) carries the usage contract, and an assistant that never calls
tell_userdegrades to today's behavior — ending the turn to speak. Monitor during rollout; if under-use proves chronic, a future RFD can explore builtin-contributed prompt sections that ship with the tool and follow its enable state, rather than JP injecting per-tool text into system prompts ad hoc. - serve-web. The web viewer needs the block-aware update RFD 058 forces anyway; rendering user-audience markdown blocks reuses its existing markdown path. Under the
userview its tool rendering keys on responses rather than folding responses into requests; that inversion folds into the same update. - Inquiry events and the user view. User-facing inquiry exchanges belong in the user view — the user's own answers are user content — but assistant-resolved inquiries do not, and the distinction requires RFD 082's source attribution. The inquiry-family rules are deferred to that work.
- Compaction. Tool-call compaction policies drop request/response pairs wholesale, so a compacted history loses
tell_usermessages from its user view. Whether user-audience blocks deserve retention through compaction (the way RFD 058 retains resource metadata when content is dropped) is an open question, deferred until compaction and this design coexist.
Implementation Plan
Phase 1: Audience honoring and the user view
Depends on RFD 058 phases 1–2 (types, ToolCallResponse migration, MCP passthrough). Add the user-view accessor to jp_conversation; apply the audience filter in the provider conversion; add the per-audience map form to inline_results (scalars preserved, applying to both audiences; chat valid on the user key); add the view selector (raw default, user) to ReadEvents with the filtering rules above; implement the empty-provider-result placeholder. Tests: assistant-audience blocks never reach the user view, user-audience blocks never reach a provider payload, an all-user-audience success reaches the provider as the neutral placeholder (one test per provider), unannotated blocks render under the more permissive of the two audience settings, { user = 10, assistant = "off" } truncates user-addressed blocks while hiding assistant-addressed ones, { user = "chat" } renders explicitly user-annotated blocks as speech on stdout while unannotated blocks stay in result display, and the user view passes message and structured chat responses, drops reasoning responses, and drops tool pairs without user-audience blocks. Add the Audience and User View glossary entries.
Phase 2: Markdown block rendering
Route text/markdown text blocks (the optional mimeType field from RFD 058) through jp_md's public rendering entry point; extend the shared contract tests.
Phase 3: style.header
Add the knob (bool or "on"/"off") with the temp-line suppression, including PartialConfigDelta / FillDefaults / ToPartial coverage plus config snapshots.
Phase 4: The tell_user builtin
Executor, registration, description, parameter schema. Tests: argument validation, response block shape, provider view carries only the ack, user view carries only the message, loop continuation, live/replay equivalence. The module comment records that the builtin uses only generic mechanisms and is a builtin for packaging reasons alone.
References
- RFD 058 — typed content blocks; defines the block model, the optional text-block
mimeType, and carriesaudienceannotations type-level. This RFD adds the first audience and mimeType behaviors. - RFD 065 — the MCP-compatible superset principle governing the text-block
mimeTypeextension. - RFD 081 — the
enable = { state, allow_toggle }shape. - RFD 082 — unified inquiry event recording; its source attribution is the prerequisite for the user view's inquiry-family rules, deferred to that work.
- RFD 083 — the built-in registration pattern and the
if_namedreasoning this RFD mirrors. - RFD 072 — the plugin registry direction referenced for future distribution of bundled tools.
- RFD 048 — the four-channel output model; the terminal maps
chat-classified blocks to stdout under its contract. - RFD 011 — system notification queue; adjacent, deliberately not addressed.
- Prompting Claude Fable — Anthropic's
send_to_userguidance for long-running agents.