Pushing Results to the User
When background work produces a user-visible result — a research finding, a fetched document, a completed task list, a quote, a contract — the application has to decide two things: how the data reaches the user-facing surface, and what (if anything) the orchestrator is told about that having happened. Beach offers three patterns for the combination. None is the "right" one. Each is correct for a different kind of surface, and the choice is made per surface at design time.
The patterns differ along a single axis: how much of the rendered data the orchestrator's LLM sees. That single axis matters because LLMs, particularly under conversational pressure, will sometimes be helpful in ways the application would prefer they were not — restating a price slightly lower because the user is unhappy, paraphrasing a contract clause in plain English when the original wording was load-bearing, summarising a medical figure into a range when the patient asked what it was. The defence against that is not to remove the orchestrator from the loop. It is to think carefully about which values the orchestrator has in its context to restate.
Pattern 1 — Render with full inform
The conversation-shaped case. The data is consequential but its truth is not load-bearing on the conversation: a list of holiday options, a set of restaurant suggestions, a search result page. The orchestrator's awareness of the data is what makes the next conversational turn work. If the user replies "actually I want option three", the orchestrator that has seen the list can respond meaningfully; the orchestrator that has not, cannot.
await uiBridge.publishSurfaceUpdate(sessionId, renderResults(results));
await sessionManager.inject({
turnId,
message: {
role: 'user',
content: `Twelve hotel options rendered. Top three by rating: ${summary}.`,
},
});
The orchestrator sees the summary. It can comment, recommend, narrow the search, or invite the user to refine. The LLM's tendency to be agreeable does no harm here because nothing it says revises the rendered facts on screen — the rendered list is the source of truth, the orchestrator is its conversational companion.
This is the right pattern when the data is shaped to invite conversation about it.
Pattern 2 — Render with positional inform only
The high-stakes case. Prices, contract terms, medical results, legal advice, signed booking confirmations, regulatory disclosures. The data must reach the user faithfully, with no LLM in the rendering path; and the orchestrator must not be tempted to restate or paraphrase it under conversational pressure. The inject carries only the fact that rendering happened — not the values themselves.
await uiBridge.publishSurfaceUpdate(sessionId, renderQuote(quote));
await sessionManager.inject({
turnId,
message: {
role: 'user',
content:
`A booking quote has been rendered for the user to review. ` +
`Do not restate the price, the dates, or any contractual figures. ` +
`If the user asks about the quote's contents, refer them to the ` +
`displayed values; do not re-derive, summarise, or paraphrase them. ` +
`If the user wants changes, trigger the appropriate quote workflow.`,
},
});
The orchestrator is told that something happened, and where, and the kind of thing it was. It is not given the figures. It cannot mangle what it does not have. If the user replies "can we make that cheaper?", the orchestrator's options are bounded — re-trigger the quote workflow with different parameters, or refer the user to the displayed values — but it cannot say "I've reduced it to £350" because £350 is not in its context to claim.
The defence here is twofold and works as a pair. The orchestrator's system prompt forbids restatement of the rendered values; the inject does not contain them. A prompt alone is not sufficient — an LLM under enough conversational pressure may attempt restatement of values it has been told not to. The defence is that the values are not in its context.
A reusable prompt fragment for actors that operate alongside truth-load-bearing surfaces ships with @cool-ai/beach-llm as displayedValuesSnippet, joining respondToolSnippet and turnStatesSnippet:
When the user is shown structured data (a quote, an itinerary, a contract,
a medical result, a regulatory disclosure), you may be informed that the
data has been rendered. You are not given the underlying values. Do not
invent them. Do not restate, summarise, recompute, or paraphrase them.
Refer the user to the displayed surface. If the user asks for changes,
trigger the appropriate tool to produce new structured data; do not
adjust values yourself.
This is the right pattern when the data must reach the user faithfully and the orchestrator must remain part of the conversation without being able to revise what is on screen.
Pattern 3 — Render with no inform
The system-or-audit case. A live operations dashboard. A reminder fired at 9 a.m. by cron because the user scheduled it. A push notification triggered by an upstream webhook. There is no awaiting turn to inject into; the orchestrator is not part of this signal at all; informing it would be a fabricated event.
await uiBridge.publishSurfaceUpdate(sessionId, renderActivityLog(events));
// No inject. The orchestrator does not know.
The diagnostic question: is there a turn waiting for this result? If the answer is no, the orchestrator is not in this loop. If the answer is yes, one of the first two patterns applies, and which one depends on the data.
This is the right pattern when the surface is genuinely orthogonal to the conversational track.
Choosing between the three
Two questions decide the pattern:
Is there an awaiting turn? If no, Pattern 3. The orchestrator is not informed because it is not part of this signal.
If yes, can the orchestrator afford to see the values? If the data is conversation-shaped — invites discussion, comparison, refinement, the user expects the agent to engage with it — Pattern 1. If the data is truth-load-bearing — the user must trust that what they see is what the application intends, and an LLM-paraphrased version would be worse — Pattern 2.
The same application typically uses all three. A travel-planning agent renders the discover-grid (twelve hotels) with full inform; renders the booking quote with positional inform only; renders the daily ops digest to an internal dashboard with no inform. The choice is per surface, decided at the point each renderer is wired, and is part of the application's design — not a Beach rule.
What inject() actually does
The session manager keeps a registry of awaiting turns. inject({ turnId, message }) looks up the turn, appends message to its message thread, and re-invokes the actor with the extended thread. The actor's next iteration sees the inject as a tool result and decides what to do — reply, ask a clarifying question, or call another tool.
If the turn no longer exists (cancelled, deleted), inject() returns 'dropped-cancelled' or 'dropped-deleted'. When a ManifestRegistry is configured, the message is held in an orphan queue keyed results-collector:${turnId} for up to five minutes, which covers the race where the inject arrives milliseconds before the turn is fully set up.
The mechanism is the same across Patterns 1 and 2. What differs is what the message contains.
filter-and-distribute — the canonical wiring
@cool-ai/beach-starter's filter-and-distribute handler covers the inject side of the work. Configure it with the manager and a summarize function whose output shape depends on which pattern the surface uses:
import { registerCanonicalHandlers } from '@cool-ai/beach-starter';
registerCanonicalHandlers(router, {
// … other options …
filterAndDistribute: {
manager: sessionManager,
summarize: async (raw, ctx) => {
// Pattern 1 — full summary
if (ctx.surface === 'discover-grid') {
return { role: 'user', content: summariseHotels(raw) };
}
// Pattern 2 — positional only
if (ctx.surface === 'quote') {
return {
role: 'user',
content: `A booking quote has been rendered. Do not restate values; refer the user to the display.`,
};
}
// Pattern 3 cases never reach this handler — they have no awaiting turn
throw new Error(`Unknown surface ${ctx.surface}`);
},
},
});
The application is responsible for two things at once: the deterministic render, and the inject whose content matches the chosen pattern. Beach's primitives handle both; the design decision is the application's.
Multiple parallel results — ResultsCollector
When the orchestrator dispatches several research tasks in parallel and wants to react to all of them together, individual injects fire as each result arrives, and the orchestrator may reply after seeing only the first. For "wait until all of them have arrived" behaviour, use ResultsCollector — an Assembly Manifest. The same patterns apply: an assemble() outcome of type inject carries either a full summary (Pattern 1) or a positional message (Pattern 2), depending on the surface the assembled data will reach.
See Manifests for the full pattern.
Pitfalls
Sending the full payload as a Pattern 1 inject. The orchestrator's context is finite. A 10MB research dump injected as a tool result costs an enormous prompt and may exceed the model's context window. Summarise. Persist the full data to the missive store; inject a short digest. CR-121's truncate-and-stage support fetches more on demand when the orchestrator needs it.
Using Pattern 1 for truth-load-bearing data. The classic LLM-pleasing failure: a user pushes back on a price, and the orchestrator — having the price in its context — proposes a "small adjustment" the application never authorised. Pattern 2 prevents this by withholding the values from the orchestrator entirely. Audit the surfaces where prices, contractual terms, regulated language, or other truth-load-bearing values appear, and use Pattern 2 for each.
Using Pattern 2 without the prompt fragment. Withholding the values from the inject is necessary but not sufficient on its own. An orchestrator whose system prompt does not forbid restatement may, under pressure, infer or paraphrase. Ship displayedValuesSnippet (or the application's own equivalent) in every actor that operates alongside Pattern 2 surfaces.
Using Pattern 3 when there is an awaiting turn. Rendering without informing an awaiting orchestrator means the orchestrator's reply arrives next saying "I am still searching" while the user is already looking at the result. The two voices clash. If a turn is awaiting, choose Pattern 1 or Pattern 2; do not silently stop.
Treating inject() failures as silent. inject() returns 'delivered' | 'dropped-cancelled' | 'dropped-deleted', and (per CR-121) 'delivered-truncated' | 'delivered-staged-only' when truncation kicks in. A dropped delivery means the user is waiting on an orchestrator that will not resume. Log the result and consider an escalation — a fallback message, an error event — whenever drops appear.
Related
- Manifests —
ResultsCollectorand Assembly Manifests in detail. - Anti-patterns — render-without-inform when there is an awaiting turn (which is the genuine anti-pattern), and the inject-with-data anti-pattern for truth-load-bearing surfaces.
- Using the starter scaffold —
filter-and-distributeconfiguration. - Creating routing rules — how research events get routed to
filter-and-distribute. - Reference: respond-tool —
respondToolSnippet,turnStatesSnippet, anddisplayedValuesSnippet. - Reference: design principles — principle 3.1 (background results dispatch immediately).