cloudflare / cloudflare/agents

First-class events, render-only messages, and typed metadata in Think

Open
#1,676 0 comments 0 reactions 1 assignee Claimed by @threepointone View on GitHub
think
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

## Summary

Apps building rich chat agents on Think (Seal / CTO-agent is the concrete driver) repeatedly hand-roll three things the framework has no native model for:

1. **Events** — non-user/assistant "happenings" that belong in the transcript (notifications, reactions, scheduled-task fires, session lifecycle) and that can *drive a turn*.
2. **Render-only / model-invisible messages** — content that should render in the UI but be excluded from the model's context (and the inverse: in-context but hidden from the UI).
3. **Typed metadata** — structured, first-class metadata on messages/turns instead of an opaque `Record`.

This issue captures the requirements while they're fresh. **It is not blocking** any current consumer — Seal ships its migration with these as app-side sidecars. The intent is to build this *after* a real consumer (the Seal migration) has validated the exact API shape, then let that consumer delete its hand-rolled machinery.

## Background / driver

Seal is migrating its bespoke chat engine onto Think (`Think` + `Session`). During that work it keeps an `event_queue` table and a `role:"event"` message category, and stamps a large amount of state onto a stringly-typed `metadata` blob. The migration is deliberately designed so these stay **app-side sidecars** — Think doesn't need to change for Seal to reach GA. But they're the obvious next things for the framework to absorb, and Seal is a good source of validated requirements.

## The three capabilities

### 1. Render-only / model-invisible messages — *highest priority, has a real dependency*

Think's `Session` today effectively assumes "in the tree ⇒ in `getHistory()` ⇒ in model context ⇒ rendered." Real apps need three distinct visibility classes:

| Class | Example | Need |
| --- | --- | --- |
| In context **+** rendered | normal user/assistant turns | native `UIMessage` (exists today) |
| In context, **NOT** rendered | a hidden setup/preamble message | metadata flag; client hides |
| Rendered, **NOT** in context | event cards, compaction markers | **a message/part Think excludes from `getHistory()` / model input** |

The third class is the missing primitive. Without it, "transcript happenings" (events, compaction notes) can only live as a UI side-channel, never as real `Session` rows / `data-*` parts.

**Why it's priority:** a UI rewrite that renders events as native typed `data-*` parts *depends* on this. If Think can't exclude a message/part from model context, those parts can never become Session-native and stay a parallel render layer indefinitely.

**Sketch (illustrative, not prescriptive):**
```ts
// per-message or per-part:
session.appendMessage(msg, { modelVisible: false }); // rendered, excluded from getHistory()
// and the inverse, already partially expressible via metadata:
session.appendMessage(msg, { uiVisible: false }); // in context, client hides
```
`getHistory()` (model input) and a separate "transcript view" diverge based on these flags.

### 2. First-class events / event-driven turns — *ergonomics, no blocker*

Generalize the `event_queue` → drain → run-a-turn pattern. Apps accumulate async "events" (webhook, reaction, schedule, sub-system notification), batch them, and periodically run a turn that *reacts* to the batch, with the turn's outcome deciding whether to reply / stay silent / discard the event.

What apps currently rebuild: an event table, batching/dedup, retry-with-backoff, a scheduled drainer, projecting the batch into model input, and a post-turn "settle the batch" step keyed off the turn's disposition.

Note: turn *serialization / idempotency / recovery* is already covered well by `messageConcurrency` + the durable submission store — so this is specifically about the **event accumulation + event→turn projection + disposition-driven settlement** layer, not ordering.

**Open question:** is this a new `Session`-adjacent primitive, or sugar over `submitMessages` + a typed event store + an `onChatResponse`-style settle hook?

### 3. Typed metadata — *ergonomics, no blocker*

Today everything rides `metadata?: Record` on messages and `SubmitMessagesOptions.metadata`. Real apps stamp many well-known fields (hidden-from-user, is-reaction, platform/author routing, invoked-skill caption, is-summary, command/command-result, reaction status/notes, attachments). A typed metadata story — generics on the agent/session for message metadata, with the framework preserving it across persistence/recovery/protocol — removes a class of stringly-typed bugs.

**Sketch:**
```ts
class MyAgent extends Think { ... }
// metadata typed end-to-end: appendMessage, getHistory, protocol frames, onChatResponse
```

## Priority / sequencing

| Item | Blocks a consumer? | When |
| --- | --- | --- |
| Render-only / model-invisible messages | **Yes** — Seal's native-parts renderer (post-adapter step) | build first |
| First-class events primitive | No (event_queue sidecar works) | after a consumer validates shape |
| Typed metadata | No (opaque metadata works) | after; can land incrementally |

Recommended sequence: let the Seal migration (reaction-ingress + native-parts renderer) run against the sidecar approach; harvest the validated API requirements; then build, with render-only-messages first since it has the only real downstream dependency.

## Non-goals

- Replacing `messageConcurrency` / the durable submission store — those already own turn serialization, idempotency, and recovery.
- A bespoke streaming channel for events (see the parked multi-channel `ResumableStream` discussion in `wip/inline-sub-agent-events.md` — different problem).

## Acceptance criteria (draft)

- A `Session` message/part can be marked rendered-but-model-invisible; `getHistory()` (model input) excludes it while a transcript API includes it.
- The inverse (in-context, UI-hidden) is expressible without an app-side filter convention.
- Message metadata is type-parameterizable and preserved across persistence, hibernation/recovery, and protocol frames.
- (Stretch) An events primitive that covers accumulation + batch→turn projection + disposition-driven settlement, demonstrated by Seal deleting its `event_queue` sidecar.

## References

- Seal migration design (CTO-agent monorepo): `apps/seal/docs/THINK-MIGRATION.md` (parity matrix #16 — event_queue stays a sidecar); `apps/seal/docs/THINK-PHASE5-PROTOCOL.md` §6 (StoredMessage→UIMessage mapping; the three visibility classes; §3b.1 render-only-messages assumption).
- Related shipped/adjacent: `messageConcurrency` + durable submissions (`design/think-durable-submissions.md`), `data-*` parts, `metadata` on `SubmitMessagesOptions`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.