Manifests — Assembling and Delivering
A Manifest is Beach's primitive for asynchronous, multi-source coordination. It tracks a set of expected slot keys, accepts deliveries against those slots, and fires onComplete once every slot is filled — or onTimeout once the deadline has passed. There is one primitive and one API. The same primitive shows up in two distinct positions in a Beach application, however, and each position has its own name: the Assembly Manifest and the Delivery Manifest.
Knowing which one is in hand tells you what the slots are and what the settlement does.
The shape of a Manifest
import { Manifest, ManifestRegistry } from '@cool-ai/beach-core';
const registry = new ManifestRegistry();
const manifest = new Manifest({
id: 'search:rome',
expected: ['flights', 'hotels'],
timeoutMs: 10_000,
onComplete: async (filled) => {
// filled: Map<string, unknown>
const flights = filled.get('flights');
const hotels = filled.get('hotels');
// … do something with both
},
onTimeout: async (filled) => {
// filled: Map<string, unknown> — only the slots that arrived
console.warn('search timed out');
},
});
registry.register(manifest);
// Deliveries — keyed by the manifest's id
registry.deliver('search:rome', 'flights', flightResults);
registry.deliver('search:rome', 'hotels', hotelResults);
// → both slots filled → onComplete fires
Three properties of the registry are worth knowing:
- Concurrent manifests are isolated. Two manifests that both expect a slot called
'main'do not conflict, because each delivery is keyed to a specific manifest id. - Pre-registration deliveries are buffered. A
registry.deliver(id, key, data)call made before the manifest with that id has been registered will be held in an orphan queue and applied as soon as the manifest registers. - Orphans expire. The default orphan TTL is five minutes. Tune through
ManifestRegistry({ orphanTtlMs }).
Assembly Manifest — downstream of the orchestrator
Position. Inside a turn, between the orchestrator and the work it dispatched. Slots. The sub-results of that work. Settlement. Re-injects the composed result into the awaiting turn.
When the orchestrating LLM dispatches several parallel research tasks — each one a routed event — it returns from respond() with turnState: 'awaiting'. Behind the scenes, an Assembly Manifest tracks which sub-results are still outstanding. As soon as all of them have arrived, the composed result is inject()-ed back into the turn, and the LLM sees it as a tool result and decides what to do next.
@cool-ai/beach-session's ResultsCollector wraps this pattern.
import { ResultsCollector } from '@cool-ai/beach-session';
import { ManifestRegistry } from '@cool-ai/beach-core';
const registry = new ManifestRegistry();
const manager = new SessionTurnManager({ router, manifestRegistry: registry });
new ResultsCollector({
turnId,
sessionManager: manager,
registry,
expected: ['flights', 'hotels'],
timeoutMs: 10_000,
assemble: (filled) => ({
type: 'inject',
message: {
role: 'user',
content: JSON.stringify({
flights: filled.get('flights'),
hotels: filled.get('hotels'),
}),
},
}),
onTimeout: (filled) => ({
type: 'inject',
message: {
role: 'user',
content: `Partial results (some specialists timed out): ${JSON.stringify(Object.fromEntries(filled))}`,
},
}),
});
// Specialists dispatch results, keyed to the manifest id `results-collector:${turnId}`
registry.deliver(`results-collector:${turnId}`, 'flights', flightResults);
registry.deliver(`results-collector:${turnId}`, 'hotels', hotelResults);
// → assemble() runs → manager.inject() fires → the orchestrator's turn resumes
assemble() returns an AssemblyOutcome:
type |
Effect |
|---|---|
inject |
Re-invokes the orchestrator's turn with message appended |
events |
Routes the listed RouteEvents; the turn is not re-invoked. Use this when the assembled result is for downstream consumers, not for the LLM. |
reset |
Re-opens the manifest for a corrective re-search; events may optionally be routed first. |
reset exists for the case where the assembled data is incoherent and the orchestrator needs to redo the search — a travel search where flights and hotels turn out to be on incompatible dates, for instance.
When to use an Assembly Manifest
- The orchestrator dispatches several parallel background tasks and needs them all back before continuing.
- The composed result is for the LLM to react to, not for direct rendering.
- A single coherent re-injection is preferable to the LLM seeing each result land on its own.
What @cool-ai/beach-starter's filter-and-distribute does
filter-and-distribute is a thin wrapper around the inject pattern, but it does not open an Assembly Manifest. It injects each result one at a time, as it arrives. Use a ResultsCollector (an Assembly Manifest) when the orchestrator needs all the results before reacting; use filter-and-distribute when results can be injected one by one.
Delivery Manifest — upstream of the channel
Position. Outside the turn, gating a batched outbound channel. Slots. The pieces the outbound message needs (
main_reply, optionallyattachments,subject_override, and so on). Settlement. Sends the channel-native message.
A batched channel — email, SMS, push — cannot stream the orchestrator's interim respond() parts as they arrive. It needs to wait for the turn to settle, possibly compose the message from several sources, and send once. The Delivery Manifest is what holds the outbound until everything is ready.
The pattern runs as follows. The inbound edge opens the manifest when a message arrives; the orchestrator runs to settlement; the consumer fills main_reply from the settled respond() parts; and the manifest's onComplete formats and sends.
import { Manifest, ManifestRegistry } from '@cool-ai/beach-core';
import { EmailChannel } from '@cool-ai/beach-channel-email';
import { EmailFormatter } from '@cool-ai/beach-format-email';
const registry = new ManifestRegistry();
const formatter = new EmailFormatter({ composer, from: 'agent@example.com' });
const channel = new EmailChannel({
// … IMAP/SMTP config …
onInbound: async (inboundMissive) => {
await missiveStore.write(inboundMissive);
// 1. Open the Delivery Manifest. It waits for `main_reply`.
const manifestId = `email-delivery:${inboundMissive.id}`;
const manifest = new Manifest({
id: manifestId,
expected: ['main_reply'],
timeoutMs: 60_000,
onComplete: async (filled) => {
const artifact = await formatter.format({
inbound: inboundMissive,
filledSlots: filled,
});
await channel.send({
to: artifact.to,
subject: artifact.subject,
text: artifact.text,
html: artifact.html,
inReplyTo: artifact.inReplyTo,
references: artifact.references,
});
},
onTimeout: async () => {
await opsAlert(`email-delivery timeout for ${inboundMissive.id}`);
},
});
registry.register(manifest);
// 2. Run the orchestrator turn. Interim respond() parts (with turnState: 'awaiting')
// are produced and discarded by this batched edge — only the final settled state matters.
const settled = await sessionManager.runTurn({
sessionId: await resolveSession(inboundMissive),
turnId: randomUUID(),
slotKey: 'concierge.reply',
actorId: 'concierge',
actorConfig, registry: tools, provider,
inboundMessage: {
role: 'user',
content: inboundMissive.parts[0].text!,
},
});
// 3. Fill the Delivery Manifest with the final settled parts.
registry.deliver(manifestId, 'main_reply', { parts: settled.parts });
// → onComplete fires → the email goes out
},
});
Composite Delivery Manifests
Some outbound shapes need more than the LLM's reply. An email with a required attachment, an SMS that must include a confirmation code, a push notification with a deep-link URL — each wants several slots filled by different sources before the message ships.
const manifest = new Manifest({
id: `email-delivery:${inboundMissive.id}`,
expected: ['main_reply', 'subject_override', 'attachments'],
timeoutMs: 60_000,
onComplete: async (filled) => {
// Compose using all three slots
},
});
registry.register(manifest);
// Slot 1 — the LLM's reply
registry.deliver(manifestId, 'main_reply', { parts: settled.parts });
// Slot 2 — a deterministic subject-line generator (perhaps from CRM data)
registry.deliver(manifestId, 'subject_override', { subject: `Re: ${customer.lastTicketTitle}` });
// Slot 3 — attachments queued from a separate workflow
registry.deliver(manifestId, 'attachments', { items: await attachmentQueue.flush() });
The outbound waits for all three slots before sending. Any one of them may take seconds or minutes; the channel does not ship a partial.
When to use a Delivery Manifest
- The outbound channel is batched (email, SMS, push) and cannot stream.
- The outbound message is composed from several sources, not just the LLM's reply.
- A strong "do not ship until ready" guarantee is needed — the manifest's all-or-timeout settlement is what supplies it.
When not to use a Delivery Manifest
- Streaming channels (SSE, WebSockets) consume parts as they arrive; a manifest would buffer them needlessly.
- Single-slot outbounds where the same code opens the manifest and fills the slot in the same flow are just synchronous code dressed up in ceremony.
One class, two positions
Manifest is one class with one API. The two names — Assembly and Delivery — describe who opens the manifest and who subscribes to its completion, not which class is instantiated. Naming the position helps the reader reason about the flow:
- "Open an Assembly Manifest" — the orchestrator is dispatching parallel work; settlement is
inject(). - "Open a Delivery Manifest" — the channel inbound is gating an outbound; settlement is
channel.send().
Both kinds can be in flight for the same user message: an Assembly Manifest inside the orchestrator's turn, waiting for parallel research, and a Delivery Manifest outside it, waiting for the orchestrator to settle so the email can ship. They do not interact — different positions, different ids, different completion semantics.
Common pitfalls
Treating an Assembly Manifest as if it were a Delivery Manifest. The orchestrator settles its turn with respond({ turnState: 'complete', parts: [...] }). If a "Delivery Manifest" is doing nothing more than consuming parts and shipping them, no manifest is needed — onTurnSettled is the right hook.
Treating a Delivery Manifest as if it were Assembly. Filling main_reply from inside the orchestrator's tool loop is wrong: the orchestrator cannot be both the producer of the reply and the filler of the slot that waits for it. Settle the turn first; fill the slot from the settled parts in the outbound edge.
Forgetting timeoutMs on a Delivery Manifest. A batched outbound that never sends because one of three slots never filled is a silent failure. Set a timeout and decide what to send — or whom to escalate to — if a slot is missing.
Using a single-slot Delivery Manifest for the sake of symmetry. If the outbound is { parts: settled.parts } and nothing more, skip the manifest and fill from onTurnSettled directly. Manifests are for coordinating across multiple slot-fillers; one slot, one filler, is over-engineered.
Related
- Pushing results to the user — when to use Assembly Manifests for research fan-out.
- Setting up email — Delivery Manifests in a real email outbound.
- Streaming vs batched edges — why batched outbounds need Delivery Manifests and streaming ones do not.