respond({ domain-data }) Cookbook
The actor's respond() tool accepts a parts array. Each part has a partType and a payload. The domain-data part type is the canonical place to put structured output that downstream code consumes by shape, not by parsing text.
A pattern that recurs across Beach applications, especially during the first integration: an actor that should emit structured output emits it as JSON inside a text part instead. Downstream code then regex-extracts the JSON, hand-parses it, and silently drops the result on parse failure. The discipline is right — respond() is structured — but the worked example of how to put structured payloads inside respond() is missing from the canonical docs. This cookbook closes that gap.
Three patterns cover almost every case.
The anti-pattern this cookbook replaces
// In the actor's prompt:
// "Respond with a JSON object: { decision: 'route' | 'reject', confidence: 0..1 }"
// In the handler:
const reply = result.parts.find((p) => p.partType === 'response')?.text;
const match = reply?.match(/\{[\s\S]*\}/);
if (!match) throw new Error('No JSON found');
const decision = JSON.parse(match[0]); // silently drops on parse failure
Failure modes:
- The LLM emits prose around the JSON. The regex match might still work; the
JSON.parsemight still succeed; the result might still be wrong (the LLM "explained" the decision differently from what the JSON literal says). - The LLM forgets to emit JSON at all. The regex returns
null; the handler either throws or substitutes a default. - The schema drifts. The handler reads
decision.priority; the prompt now says emitdecision.urgency; the parse succeeds, the lookup returnsundefined, the handler defaults silently. - The audit log shows free text; replaying the call doesn't reproduce the structured decision because the structure is invented at parse time.
respond({ parts: [{ partType: 'domain-data', ... }] }) removes every one of these.
Pattern 1 — the classifier
A single structured decision. The actor reads context, picks one of N options, returns the choice with confidence and a short reason.
The actor's tool
Declare a respond augmentation that the actor must populate:
import { ToolRegistry } from '@cool-ai/beach-llm';
const tools = new ToolRegistry();
tools.register({
name: 'respond',
scope: 'specialist',
routing: 'bypass',
bypassRouting: { reason: 'respond is the canonical actor-output tool' },
description: 'Emit the actor turn output. parts MUST include exactly one `domain-data` part with the triage decision.',
inputSchema: {
type: 'object',
properties: {
parts: {
type: 'array',
items: {
oneOf: [
{ /* response, thinking, etc. — Beach defaults */ },
{
type: 'object',
properties: {
partType: { const: 'domain-data' },
dataType: { const: 'triage-decision' },
data: {
type: 'object',
properties: {
classification: { enum: ['urgent', 'routine', 'spam', 'requires-clarification'] },
confidence: { type: 'number', minimum: 0, maximum: 1 },
reasoning: { type: 'string' },
},
required: ['classification', 'confidence', 'reasoning'],
},
},
required: ['partType', 'dataType', 'data'],
},
],
},
},
turnState: { const: 'complete' },
},
required: ['parts', 'turnState'],
},
handler: async () => { /* respond-tool ack — Beach handles via callActor */ },
});
The schema is the contract. The LLM must emit a domain-data part with dataType: 'triage-decision' whose data matches the inner schema. Anthropic's tool-use enforces the schema; mismatches surface at the API level, not in your handler.
The actor's prompt fragment
You are an email triage classifier. For each inbound email, decide:
- urgent: needs same-day response
- routine: can wait 24-48h
- spam: can be auto-archived
- requires-clarification: not enough information to decide
Always end your turn by calling respond(). The respond() input MUST include
exactly one domain-data part with dataType "triage-decision". Set
classification to one of the four values. Set confidence to your subjective
likelihood that the classification is correct (0..1). Keep reasoning to one
short sentence.
No "respond with JSON". No bracket-wrapped instructions. The schema is enforced by the tool registration; the prompt just describes when to call respond().
The handler reads the typed part
import { callActor } from '@cool-ai/beach-llm';
router.register('email_triage_handler', async (event, ctx) => {
const result = await callActor({
config: triageActorConfig,
messages: [{ role: 'user', content: event.data.text }],
sessionId: event.data.sessionId,
turnId: event.data.turnId,
slotKey: 'triage',
registry: tools,
provider,
signal: ctx.signal,
});
// The decision is on the domain-data part — typed, structured, audit-logged.
const decisionPart = result.respond.parts.find(
(p) => p.partType === 'domain-data' && p.dataType === 'triage-decision',
);
if (!decisionPart) {
// Schema-enforced — this should never happen if the tool is registered correctly.
// If it does, the failure is loud and the audit log shows what happened.
throw new Error('Triage actor did not emit a triage-decision part');
}
const { classification, confidence, reasoning } = decisionPart.data;
await ctx.routeEvent({
source: 'triage',
eventType: 'classified',
data: { /* the typed decision propagates */ classification, confidence, reasoning },
});
});
What's gone: the regex, the JSON.parse, the silent-default-on-failure, the schema-drift bug. What's added: a typed-part lookup, a hard error when the contract isn't met, an audit log entry showing the structured decision.
Pattern 2 — structured extraction
The actor extracts a list of entities from text. Each entity has a fixed shape; the count varies.
// Tool registration — domain-data part carries an array
{
partType: { const: 'domain-data' },
dataType: { const: 'extracted-people' },
data: {
type: 'object',
properties: {
people: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
role: { type: 'string' },
email: { type: 'string', format: 'email' },
},
required: ['name'],
},
},
sourceConfidence: { enum: ['high', 'medium', 'low'] },
},
required: ['people', 'sourceConfidence'],
},
}
The handler:
const part = result.parts.find(
(p) => p.partType === 'domain-data' && p.dataType === 'extracted-people',
);
if (!part) throw new Error('Extraction actor produced no extracted-people part');
const { people, sourceConfidence } = part.data;
for (const person of people) {
await capabilityRegistry.invoke('person-upsert', { person });
}
Same shape as the classifier. The list is typed, the schema is enforced, the handler iterates. No regex.
Pattern 3 — tool-result-style schema declaration
The actor performs work and returns the result as if it were a tool output: full structured response, no narrative wrapping. Common for actors that drive deterministic backend operations from natural-language instructions.
// The actor's domain-data shape mirrors what a tool would return
{
partType: { const: 'domain-data' },
dataType: { const: 'flight-search-result' },
data: {
type: 'object',
properties: {
flights: {
type: 'array',
items: {
type: 'object',
properties: {
airline: { type: 'string' },
flightCode: { type: 'string' },
depart: { type: 'string', format: 'date-time' },
arrive: { type: 'string', format: 'date-time' },
priceGbp: { type: 'integer' },
},
},
},
narrative: { type: 'string' }, // optional natural-language summary alongside the data
},
required: ['flights'],
},
}
The handler treats the actor's output the same way it would treat a tool's output. Downstream code consumes flights[] directly; if a narrative is needed (chat-panel, peer LLM), it reads narrative separately.
This pattern composes cleanly with FilterAndDistribute: the structured flights[] becomes the load-bearing destination payload; the narrative is the LLM-session destination's view. See Filter-and-distribute for the dispatch pattern.
When text parts are still the right answer
Not everything is structured. The conversational reply itself ("Here are the flights I found, the BA option is most convenient") is a response part, not domain-data. The typical actor turn has both:
parts: [
{ partType: 'response', text: 'Here are the flights I found...' },
{ partType: 'domain-data', dataType: 'flight-search-result', data: { flights: [...] } },
]
The reply is for humans (chat panel, email body). The data is for everything else (cache, peer LLM, audit, downstream tools). They live alongside each other; the surface chooses which part to render.
Pitfalls
Asking the LLM to emit JSON in the text part. This is the anti-pattern this cookbook replaces. The fix is always to declare the structured payload as a domain-data part on the respond() schema; the prompt then says "call respond() with the result" rather than "respond with a JSON object".
Giving the LLM a schema in the prompt instead of in the tool registration. Anthropic's API enforces tool-input schemas at the API level; schemas in prose are advisory only. The structured-output discipline depends on the schema being mechanical. Always declare the schema on the tool, not in the prompt.
Multiple domain-data parts of the same dataType in one turn. The handler reads the first match. Either declare distinct dataType values per logical record (extracted-people, extracted-organisations, separate parts) or pack a single record with a list (extracted-entities with a tagged-union list inside). Don't have the actor emit two domain-data parts both labelled triage-decision.
Treating absence-of-domain-data as recoverable. If a handler genuinely needs a structured decision and doesn't get one, the right move is to throw (ActorMissingRespondError if the actor produced no respond() at all; a typed MissingDomainDataPart error if the actor produced a respond() without the expected domain-data). Silent defaults are how the JSON-in-text anti-pattern got entrenched in the first place.
Putting unknowns in domain-data. The schema enforcement only works if the schema is honest about what it expects. If a field is genuinely optional, mark it optional; don't widen the type to unknown to dodge schema work. unknown defeats the typed-part discipline.
Related
@cool-ai/beach-llmREADME § Tool registration —ToolRegistryAPI.- Filter-and-distribute — how
domain-dataparts compose with the per-destination dispatch primitive. - Anti-patterns — the broader set of integration-time pitfalls.
- Adding a Participant — the wiring story for handlers and actors.