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 slots fill, onComplete routes the composed result back via router.routeEvent(), and a routing rule maps it to a fresh actor invocation — the orchestrator sees the assembled data and continues the turn.

The raw Manifest API coordinates the collection. When all slots fill, onComplete routes the composed result back through the router so the orchestrator can react:

import { Manifest, ManifestRegistry } from '@cool-ai/beach-core';

const registry = new ManifestRegistry();

const manifest = new Manifest({
  id: `results-collector:${turnId}`,
  expected: ['flights', 'hotels'],
  timeoutMs: 10_000,
  onComplete: async (filled) => {
    await router.routeEvent({
      source: 'assembly',
      eventType: 'slots_filled',
      data: {
        sessionId, turnId,
        flights: filled.get('flights'),
        hotels:  filled.get('hotels'),
      },
    });
  },
  onTimeout: async (filled) => {
    await router.routeEvent({
      source: 'assembly',
      eventType: 'slots_filled',
      data: {
        sessionId, turnId,
        partial: true,
        flights: filled.get('flights'),
        hotels:  filled.get('hotels'),
      },
    });
  },
});
registry.register(manifest);

// Specialists dispatch results, keyed to the manifest id
registry.deliver(`results-collector:${turnId}`, 'flights', flightResults);
registry.deliver(`results-collector:${turnId}`, 'hotels',  hotelResults);
// → both slots filled → onComplete fires → assembly:slots_filled routes to the next handler

The onComplete callback routes a follow-on event. The routing rule for that event determines what happens next:

Pattern Effect
Route to a new actor turn The composed result becomes the inbound message for a fresh turn. Use this when the LLM needs to see all results at once before deciding what to do next.
Route to a downstream handler The assembled result is for a consumer (formatter, audit, peer-response), not for re-invoking the LLM. The current turn is not resumed.
Re-open the manifest Emit a fresh manifest registration under the same id to retry the collection — useful when assembled data is incoherent and the search must run again.

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.

Inject-per-result, when each arrival can stand alone

Some research patterns deliver each result as it lands rather than waiting for all of them. The orchestrator settles a partial reply, the user sees what is ready now, and subsequent results route as follow-on events that trigger a new turn. No manifest is opened; the orchestrator re-runs each time a result arrives.

Use an Assembly Manifest when the orchestrator needs all the results before reacting; route one by one when each arrival can be reasoned about on its own.

Naming note. Beach has a separate primitive called FilterAndDistribute in @cool-ai/beach-core. It is not the inject-per-result pattern described here — it is the per-destination dispatch that fans a generalist tool's AnnotatedRecord<T> out to multiple consumers (LLM session, UI, cache, audit, peer-response) with each one receiving its own filtered view. See filter-and-distribute.md. The two patterns share a name historically but solve different problems.

Delivery Manifest — upstream of the channel

Position. Outside the turn, gating a batched outbound channel. Slots. The pieces the outbound message needs (main_reply, optionally attachments, 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 { EmailHtmlComposer }         from '@cool-ai/beach-format-email';

const registry  = new ManifestRegistry();
const composer  = new EmailHtmlComposer({ llmRender });

const channel = new EmailChannel({
  // … IMAP/SMTP config …
  onInbound: async (inboundMissive) => {
    await missiveStore.write(inboundMissive);

    // 1. Open the Delivery Manifest. It waits for `main_reply` (the data
    //    sections from the orchestrator's tools) and `narrative` (the
    //    LLM's framing prose).
    const manifestId = `email-delivery:${inboundMissive.id}`;
    const manifest = new Manifest({
      id: manifestId,
      expected: ['main_reply', 'narrative'],
      timeoutMs: 60_000,
      onComplete: async (filled) => {
        const result = await composer.compose({
          sections: [{ sectionId: 'reply', data: filled.get('main_reply') as never }],
          narrative: filled.get('narrative') as string ?? null,
          envelope: {
            channelClass: 'email-html',
            from: 'agent@example.com',
            to: [inboundMissive.origin.address!],
            ...(inboundMissive.subject !== undefined ? { inboundSubject: inboundMissive.subject } : {}),
            ...(inboundMissive.origin.messageId !== undefined ? { inReplyToMessageId: inboundMissive.origin.messageId } : {}),
            ...(inboundMissive.references !== undefined ? { references: inboundMissive.references } : {}),
          },
        });
        if (result.status === 'rendered') {
          await channel.send({
            to: result.artifact.to,
            subject: result.artifact.subject,
            text: result.artifact.plainText,
            html: result.artifact.html,
            ...(result.artifact.inReplyTo !== undefined ? { inReplyTo: result.artifact.inReplyTo } : {}),
            ...(result.artifact.references !== undefined ? { references: result.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 sessionId = await resolveSession(inboundMissive);
    const turnId    = randomUUID();
    const result = await callActor({
      config: actorConfig,
      messages: [{ role: 'user', content: inboundMissive.parts[0].text! }],
      sessionId, turnId, slotKey: 'concierge.reply',
      registry: tools, provider,
    });

    // 3. Fill the Delivery Manifest with the final settled parts.
    registry.deliver(manifestId, 'main_reply', { parts: result.respond.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: result.respond.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 routes the result back via router.routeEvent().
  • "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