Beach-Specific Anti-patterns

This article catalogues mistakes that are particular to Beach's architecture. The general engineering missteps that beset all software — poor test isolation, careless caching, unsignposted polling intervals, and the like — are well covered elsewhere; what follows here is the smaller and more important set of errors that arise specifically because Beach asks for a particular discipline and offers a particular set of primitives.

The reasoning is what matters. Each entry describes the architectural commitment that the mistake violates and the corresponding correct shape. The how-to guides referenced at the foot of each entry contain the practical wiring.

Bypassing the router from an HTTP route

The architectural commitment Beach asks of an adopter is that every cross-component message passes through the router. The HTTP route handler that translates an inbound request directly into a runTurn invocation, returning the orchestrator's reply synchronously to the caller, has bypassed this commitment entirely. The router is unaware that the request occurred; the audit trail records nothing; downstream filtering and cascade rules are silent; observability handlers do not see what they were registered to see.

The correct shape is for the HTTP route handler to act as an adapter: it translates the protocol-shaped input (an HTTP request) into a routed event and dispatches it through the router. The orchestrator picks up the event via the canonical pipeline; the reply emerges through whichever outbound surface is appropriate for the channel. The route handler waits, where it must, on a Delivery Manifest whose onComplete serialises the reply back as the HTTP response. The synchronous appearance to the HTTP caller is preserved; the architectural visibility of every cross-component message is also preserved.

The mental model worth adopting is that protocol-handling code lives at the edge and orchestrator-driving code lives in the interior. They are separated by the router. See Getting Started and Exposing a REST API on top of a Beach application.

Returning data from a handler instead of routing it onward

A Beach handler is not a function in a synchronous call chain; it is a participant in an event-routed system. The router inspects the handler's return value only to determine whether filtering should be suppressed; everything else is discarded. A handler that computes a useful result and returns it is, from the router's perspective, a handler that did some work and then made the result invisible.

The correct shape is to dispatch a follow-on event via context.routeEvent(). The handler's result becomes a routed event whose data field carries the value; a routing rule sends the new event to the next handler; the chain is explicit and legible from the routing configuration. Anything that matters to another component is an event. See Your first handler.

Mismatching the inform pattern to the surface

Three patterns are available when background work produces a user-visible result, and each is correct for a different kind of surface (see Pushing results to the user). The anti-patterns are not in choosing one of the three; they are in choosing the wrong one for the surface in front of you.

Render-without-informing when there is an awaiting turn, on a conversation-shaped surface. A research handler renders twelve hotels and stops. The orchestrator, oblivious, finishes its turn and replies "I am still searching for options" while the user is already looking at the result. The two voices clash. For a conversation-shaped surface, full inform (Pattern 1) is what closes the loop.

Inject-with-data on a truth-load-bearing surface. This is the failure mode that earns the patterns their existence. A booking quote is rendered; the orchestrator is told the price; the user pushes back ("can we do better?"); the orchestrator, helpful by training, proposes a "small adjustment" the application never authorised. The orchestrator did not lie — it is doing exactly what an LLM under conversational pressure does — but the application has just promised a price the rest of the system has no record of. The fix is not to remove the orchestrator from the loop; it is to keep the values out of the orchestrator's context. Use Pattern 2: render the surface, inject only the fact of rendering with an explicit instruction not to restate, and pair the inject with displayedValuesSnippet (or the application's equivalent) in the actor's system prompt. The values are then not in the orchestrator's context to revise.

Pattern 3 used when there is an awaiting turn. Rendering with no inject is correct for system-driven signals — a cron-fired reminder, an ops dashboard — where no orchestrator turn is part of the flow. It is wrong when an orchestrator is awaiting: the orchestrator's reply will arrive in conflict with what the user has seen, because nobody told the orchestrator anything happened.

The general discipline: the choice of inform pattern is per surface, decided when the surface is wired. Audit each rendering surface for which of the three is appropriate. Conversation-shaped data invites Pattern 1; truth-load-bearing data demands Pattern 2; orthogonal system surfaces use Pattern 3. The mistake is to assume one pattern applies everywhere.

See Pushing results to the user for the worked examples and the prompt-fragment defence for Pattern 2.

Channel-aware orchestrators

The architectural commitment articulated in design principle 2.7 is that no interior component reads channel-shaped state. The orchestrator does not know whether the message it is replying to arrived by chat, by email, or by an A2A peer call; the deterministic handlers do not branch on channelId; the session state does not include channel-specific metadata.

The mistake is to bend this commitment when an early integration arrives — typically when adding email after a chat-only proof-of-concept and finding that the orchestrator's chat-shaped prose does not read well as email. The path of least resistance is to teach the orchestrator about the channel: "if channelId === 'email', write more formally and add a sign-off." Once that branch exists, every new channel becomes a prompt-modification exercise, the orchestrator's audit trail begins to record per-channel reasoning, and the architecture's central property has been lost.

The correct shape is to keep the orchestrator's reasoning channel-blind and to handle channel-specific concerns at the edge. Where the difference is genuinely about formatting, a Channel Formatter (a deterministic component) accepts the orchestrator's structured output and produces channel-native artefacts. Where the difference is genuinely about prose tone, a Composer specialist (a separate edge-positioned LLM actor) writes the connective tissue around the orchestrator's structured output. The Composer is permitted to know its channel because, by position, it is not interior. See Reference: design principles, principle 2.7.

The discipline at work is that a developer who notices themselves reaching for channelId inside the orchestrator's tool loop should stop and identify what kind of edge component is missing. The desire to reach is itself the diagnosis.

Failing to set timeoutMs on a Delivery Manifest

A Delivery Manifest gates a batched outbound channel — typically email — until the orchestrator has settled and the manifest's slots have been filled. The pattern works on the happy path. It fails silently when the orchestrator hangs, because the manifest never settles, the onComplete callback never fires, and the email is never sent. The user receives no reply; the application's logs may show that the orchestrator was running, but no record indicates that the outbound was supposed to have been sent.

The correct shape is to set timeoutMs on every Delivery Manifest and to provide an onTimeout handler that decides what to do when slots have not filled in time. The fallback might be an interim acknowledgement ("we are looking into your message and will reply shortly"), an escalation to operations, or a logged alert. The decision is application-specific; the discipline of making a decision rather than allowing the silent failure is universal across Beach applications. See Manifests and Setting up email.

Multiple routing rules for the same key without when predicates to disambiguate

Beach's router applies routing rules in declaration order and stops at the first match. Two rules for the same (source, eventType) without when predicates therefore have a deterministic outcome — the first wins, the second is dead — but not the outcome the developer typically intended. The mistake usually arises from the assumption that "both handlers should run."

If both handlers genuinely should run, the correct shape is a filtering rule: routing dispatches to one handler, and filtering fans the same event out to additional destinations after the routed handler completes. Routing is one-to-one; filtering is one-to-many. Choosing routing where filtering was meant produces silent dead code; choosing filtering where routing was meant scrambles the dispatch ordering. The two patterns are not interchangeable. See Creating routing rules.

If the two handlers should run conditionally on the payload, add a when predicate to each. The predicate DSL — equals, exists, with anyOf and allOf for combinations — is how Beach expresses payload-conditional routing. See the same guide.

requiresApproval: true without an end-to-end approval flow

Marking a tool with requiresApproval: true instructs Beach's LLM layer to intercept calls to that tool, emit an approval-request envelope part, and place the orchestrator's turn into the 'suspended' state pending a decision. The interception is silent and reliable; what fails is the rest of the path. If the user-facing surface has no rendering for approval-request parts, the user never sees the request; the turn remains suspended indefinitely; the application appears to have hung.

Approval is an end-to-end discipline, not a tool-level flag. The flow comprises four parts: the tool's requiresApproval declaration, the user-facing renderer that displays approval requests with approve and deny controls, the routing path that translates the user's response into an approval-response event, and the session manager's resumption of the suspended turn with the user's decision. All four must work together; absence of any one of them breaks the flow. See Reference: tool-registry for the four-part pattern.

Calling peer agents and assuming they speak Beach extensions

Beach contributes a small set of extensions to the A2A protocol — most notably the llm-context envelope part type — that are advertised in the Agent Card's x-beach block and that participate in inter-Beach-application calls. When the peer is itself a Beach application, the extensions function transparently. When the peer is a vanilla A2A agent (or a peer of any kind that does not advertise Beach extensions), the extensions are not present in the reply.

The mistake is to call a peer and then to read fields in the reply that depend on Beach extensions, without checking whether the peer's Agent Card advertises them. The undefined field becomes a runtime error or a silent missing value; the application's behaviour degrades in a way that is invisible from the calling code.

The correct shape is to inspect the peer's Agent Card before assuming. The card declares which extensions the peer produces and which it consumes; the calling code branches accordingly. Beach degrades gracefully to vanilla A2A when the peer does not speak Beach; the application's responsibility is to handle the degradation rather than crash through it. See Consuming other agents and Reference: agent-card.

Marking logic-error rules as retryable: true

The retry queue is for transient failures — a network blip during a peer call, a downstream service that is briefly overloaded, a connection reset that resolves on the next attempt. A handler that throws because its input was malformed will throw on every retry; the retry queue accumulates the same broken event indefinitely; the application eventually exhausts memory or the retry queue itself overflows.

The correct shape is to mark only transient-failure-prone rules as retryable: true — typically those that issue calls to external services. Logic errors should fail fast: the audit trail records the failure, the developer addresses the underlying cause, the retry queue is left for actually-recoverable conditions. Retrying a logic error masks the bug behind an apparently-resilient failure mode and makes diagnosis substantially harder.

Related