Updates the runtime agent-runner skill references to match the bounded
in-memory runner: correct the InMemoryAgentRunner store as a process-global
singleton, document its bounds and onConcurrentRun concurrency handling, note
that dedup weakens past the run cap, fix rotted in-memory.ts citations onto
stable symbols, and correct the multi-instance SqliteAgentRunner scaling
guidance. The generated skills/ mirror is regenerated in lockstep so source and
mirror stay in sync.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds a dedicated bounded-thread-store suite and extends the in-memory runner
suite to lock in the behaviors introduced with the bounded, teardown-isolated
runner:
- LRU thread eviction, per-thread run-cap FIFO trimming, and byte-ceiling
eviction (including that a live/running or stop-requested thread is never
evicted, and that a just-appended thread pushes OTHER threads out rather than
self-evicting).
- InMemoryLimits validation/normalization: invalid bounds clamp to defaults
instead of crashing enforceRunCap, and the 0/Infinity disable sentinels are
preserved.
- Thread-level createdAt and message-snapshot decoupling survive run-cap
eviction and interleaved empty-snapshot runs.
- stop() guards and the supersede path: an aborted run finalizes as a clean
RUN_FINISHED against its own captured intent, a superseded run cannot clobber
its replacement's state or history, and an immediate abort-throw that emitted
nothing creates no phantom historic run.
Also restores the shared store's default limits after tests that reconfigure
the process-global store so suites stay isolated, and updates a handle-run
comment for the GLOBAL_STORE -> shared store rename.
Co-Authored-By: Claude <noreply@anthropic.com>
A run's async teardown used to read the shared, mutable store.stopRequested to
decide whether to finalize as a clean RUN_FINISHED or a synthetic RUN_ERROR.
Under supersede (or a stop() immediately followed by a new run()), a later run
resets that shared flag, so the earlier run's fire-and-forget finalization could
be mislabelled — an intentionally stopped run finalized as an error, or vice
versa — and could push history under, or clobber the state of, the newer run
that now owns the thread.
Fix by capturing a per-run RunFinalizeControl when the run starts. stop() and a
superseding run() flip THAT run's captured control (not just the store flag),
and the run's teardown reads its own captured intent, so a later run resetting
store state can never change how an earlier run finalizes.
The teardown itself is unified into a single finalizeRun helper shared by the
success and error paths (they were near-identical and must stay symmetric) and
made ownership-aware:
- It only pushes history / resets shared store state when this run still owns
the thread (store.currentRunId still equals this run's id), so a superseded
run cannot corrupt the successor's history or state.
- The error path additionally requires at least one real (pre-finalize) event,
reviving a guard that had gone dead: an immediate throw that emitted nothing
must not create a phantom historic run holding only the synthetic terminal.
- On completion it releases the run's infinite ReplaySubject buffer via an
identity guard (store.subject === nextSubject), reclaiming the duplicate
buffer on the owning path while leaving a live successor's subject untouched.
The concurrency branch now also triggers on store.stopRequested, not just
isRunning: stop() flips isRunning off the instant it aborts but the run keeps
finalizing, and a run() slipping through that window went entirely unhandled.
The previous-subject bridge is removed: forwarding a dying superseded run's
subject would replay its RUN_STARTED and push its terminal into the live run's
stream, an invalid AG-UI sequence — a superseded run must stay isolated to its
own subscribers.
Co-Authored-By: Claude <noreply@anthropic.com>
Route every storage access in the runner through the shared ɵBoundedThreadStore
instead of the old unbounded GLOBAL_STORE Map: run() acquires threads via
getOrCreate (which applies LRU eviction), and connect/isRunning/stop/
listThreads/getThreadMessages/getThreadEvents/clearThreads read through the
store's touch-aware accessors so reads keep LRU order honest.
The constructor now accepts InMemoryLimits inline alongside onConcurrentRun.
Note the scope difference, called out in the JSDoc: onConcurrentRun is
per-runner, but the limits reconfigure the PROCESS-GLOBAL store shared by every
runner. A partial limits update coalesces each unspecified field against the
store's current effective bounds (not the hardcoded defaults), so tuning one
bound never silently resets its siblings; a genuine clobber of an
already-customized store warns once.
getThreadMessages now returns the thread-level snapshot (a shallow array-level
copy) rather than the last run's snapshot, so run-cap eviction and interleaved
empty-snapshot runs can never lose it. getThreadState is hardened to reject
arrays (which pass `typeof === "object"`) and to return a defensive shallow
copy so callers cannot mutate stored snapshot state.
Co-Authored-By: Claude <noreply@anthropic.com>
The in-memory runner previously kept every thread and run forever in an
unbounded process-global Map, so a long-lived process leaked memory without
limit. Introduce ɵBoundedThreadStore as the single backing store, enforcing
three independent bounds resolved from InMemoryLimits (defaults in
ɵINMEMORY_DEFAULTS):
- maxThreads: LRU eviction of whole threads.
- maxRunsPerThread: FIFO run-cap per thread.
- maxBytes: approximate cross-thread byte ceiling (via ɵestimateBytes),
enforced at run completion by evicting other LRU non-running threads.
Limit values are validated and normalized once (ɵnormalizeLimits /
ɵisValidLimit): only a non-negative integer or +Infinity is well-formed.
Invalid values (negatives, -Infinity, NaN, fractional caps) would otherwise
turn the `count > limit` enforcement guards into infinite loops or a shift()
of undefined; they are instead clamped to the documented default with a single
warning. Clamp-and-warn rather than throw matches this file's best-effort
posture (ɵestimateBytes swallows serialization failures), because constructing
a non-durable convenience runner must never abort — or later surface an
unhandled rejection — on a typo'd bound.
Thread creation time and the latest non-empty message snapshot are held at the
THREAD level (InMemoryEventStore.createdAt / messagesSnapshot), decoupled from
historicRuns so run-cap FIFO eviction can neither drift the reported creation
time forward nor drop the message history. Eviction — whole-thread LRU and
per-thread run-cap trimming alike — is logged once per store (warn-once latch)
so bounded history loss is visible rather than silent.
Also defines the per-run RunFinalizeControl shape and the store's
activeFinalize holder that the run-teardown isolation builds on.
Co-Authored-By: Claude <noreply@anthropic.com>
## What changed
- Mark initial gateway HTTP 5xx and transient transport failures as
retryable.
- Retry initial managed Channel activation with exponential backoff from
1 second to a 30-second cap until it connects or the manager stops.
- Preserve retry hints from `gateway_draining` join replies and retry
initial join timeouts.
- Keep HTTP 4xx and NXDOMAIN failures terminal.
- Back off established-session outage reminders from 30 seconds to a
15-minute cap while Phoenix continues reconnecting.
## Why
The OpenTag Railway runtime saw the gateway host return HTTP 502 during
an outage. Established Phoenix sessions keep retrying, but a runtime
that starts during the outage stops after its one initial connect
window. It cannot recover when the gateway comes back unless the process
restarts. Fixed 30-second reminder logs also flood long outages.
The gateway drain work now rejects new joins with a structured retryable
response. The client must preserve that response so the runtime can
retry instead of leaving the Channel in a terminal error state.
## Companion change
CopilotKit/OpenTag#25 keeps the Railway HTTP server alive while an
initial Channel retry is pending. OpenTag must consume a CopilotKit
release containing this PR before that companion change can recover by
itself.
## Validation
- `pnpm nx run-many -t test,check-types,build -p
@copilotkit/runtime,@copilotkit/channels-intelligence`
- `pnpm nx run-many -t publint,attw -p
@copilotkit/runtime,@copilotkit/channels-intelligence`
- pre-commit tests and package checks for all affected projects
- `pnpm exec oxfmt --check` on all five changed files
- `pnpm exec oxlint` on all five changed files
- `git diff --check`
## What
Fixes the intermittently-red `test / integration / runtime` **bun** leg.
Three commits, smallest blast radius first:
1. **`ci(runtime)`** — pin `bun-version` from `latest` to `1.3.14` so a
Bun release can't change module-resolution behaviour between runs. (Only
`bun-version: latest` in the repo.)
2. **`fix(deps)`** — **this is the actual fix.** Patch
`eventsource@3.0.7` to drop its `bun` export condition, via `pnpm patch`
+ `patchedDependencies`.
3. **`refactor(runtime)`** — module-graph hygiene: load the MCP SSE
transport lazily. Explicitly **not** a behaviour fix; commit 2 is.
## Root cause
```
TypeError: require() async module ".../eventsource@3.0.7/node_modules/eventsource/dist/index.js" is unsupported. use "await import()" instead.
at .../@modelcontextprotocol/sdk/dist/cjs/client/sse.js:4:7
at .../@ag-ui/mcp-apps-middleware/dist/index.js:1:983
at processTicksAndRejections (unknown:7:39)
```
- `eventsource@3.0.7` maps its `bun` export condition to the **ESM**
build (`dist/index.js`). Bun resolves `bun` **before** `require`, so a
CJS `require("eventsource")` receives an async ESM module and throws.
The package ships a real CJS build (`dist/index.cjs`) behind `require`,
but Bun never reaches it.
- Two CJS consumers in our graph hit this: the MCP SDK's own
`dist/cjs/client/sse.js`, and `@ag-ui/mcp-apps-middleware@0.0.3` — a
CJS-only package (`main: ./dist/index.js`, no `exports`, no `type:
module`) that `require`s that SDK path unconditionally at module load.
- **Why intermittent:** it's a load-order race. If the ESM graph fully
evaluates `eventsource` first, the later CJS `require` can be served
synchronously and the run passes; otherwise it throws.
Dropping the `bun` key makes Bun fall through to `import` for ESM
consumers (same `dist/index.js` as before — no behaviour change) and to
`require` for CJS consumers (`dist/index.cjs`, which is what they need).
Only `bun` is touched; `deno`/`source`/`import`/`require`/`default` are
left alone.
**A version bump is not an alternative:** `eventsource@4.1.0` still
ships the same `bun` → ESM mapping.
## Patch diff
`patches/eventsource@3.0.7.patch` (header abridged — the file carries
the full rationale and an explicit deletion criterion so it doesn't
become permanent by accident):
```diff
# Drops the `bun` export condition from eventsource.
# ...
# DELETE THIS PATCH WHEN: eventsource drops the `bun` condition or points it at
# dist/index.cjs, OR Bun stops preferring `bun` over `require` for CJS requires.
diff --git a/package.json b/package.json
@@ -10,7 +10,6 @@
"exports": {
".": {
"deno": "./dist/index.js",
- "bun": "./dist/index.js",
"source": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
```
Root `package.json` gains:
```json
"patchedDependencies": { "eventsource@3.0.7": "patches/eventsource@3.0.7.patch" }
```
This repo had no `patches/` precedent (it uses `pnpm.overrides`), so
this sets one — hence the minimal one-line patch and the documented
removal criterion.
## Red-green proof
All four states. Local runs are the **same command on the same
machine**, differing only by whether the patch is applied. Bun 1.3.14,
macOS arm64, run from `packages/runtime`:
```sh
bun test src/v2/runtime/__tests__/integration/bun/bun-servers.integration.test.ts
```
A single green run proves nothing here — it's a race — so both local
states are N=20.
### 1. CI-RED
- This branch before the patch, run
[30835752558](https://github.com/CopilotKit/CopilotKit/actions/runs/30835752558)
@ `5456e308b9` — `runtime / node` success, **`runtime / bun` failure**:
```
4 | const eventsource_1 = require("eventsource");
TypeError: require() async module "/home/runner/work/CopilotKit/CopilotKit/node_modules/.pnpm/eventsource@3.0.7/node_modules/eventsource/dist/index.js" is unsupported. use "await import()" instead.
0 pass
1 fail
```
- Also on `main` @ `26a23bbf3a`, run
[30825667393](https://github.com/CopilotKit/CopilotKit/actions/runs/30825667393)
— same leg, same failure.
### 2. LOCAL-RED (eventsource UNPATCHED, N=20)
```
run 1: 72 pass 0 fail
run 2: 0 pass 1 fail
run 3: 0 pass 1 fail
run 4: 0 pass 1 fail
run 5: 0 pass 1 fail
run 6: 0 pass 1 fail
run 7: 0 pass 1 fail
run 8: 0 pass 1 fail
run 9: 0 pass 1 fail
run 10: 72 pass 0 fail
run 11: 0 pass 1 fail
run 12: 0 pass 1 fail
run 13: 72 pass 0 fail
run 14: 0 pass 1 fail
run 15: 0 pass 1 fail
run 16: 72 pass 0 fail
run 17: 0 pass 1 fail
run 18: 72 pass 0 fail
run 19: 0 pass 1 fail
run 20: 0 pass 1 fail
LOCAL-RED TOTAL: pass=5 fail=15 (out of 20)
```
### 3. LOCAL-GREEN (eventsource PATCHED, N=20)
```
run 1: 72 pass 0 fail
run 2: 72 pass 0 fail
run 3: 72 pass 0 fail
run 4: 72 pass 0 fail
run 5: 72 pass 0 fail
run 6: 72 pass 0 fail
run 7: 72 pass 0 fail
run 8: 72 pass 0 fail
run 9: 72 pass 0 fail
run 10: 72 pass 0 fail
run 11: 72 pass 0 fail
run 12: 72 pass 0 fail
run 13: 72 pass 0 fail
run 14: 72 pass 0 fail
run 15: 72 pass 0 fail
run 16: 72 pass 0 fail
run 17: 72 pass 0 fail
run 18: 72 pass 0 fail
run 19: 72 pass 0 fail
run 20: 72 pass 0 fail
LOCAL-GREEN TOTAL: pass=20 fail=0 (out of 20)
```
**5/20 → 20/20.**
### 4. CI-GREEN
The `test / integration / runtime` bun leg on this PR is the
load-bearing evidence. See checks below.
## Clean-install verification
A patch that only works incrementally is worthless in CI, so this was
verified from scratch — every `node_modules` in the workspace deleted,
then `pnpm install --frozen-lockfile`:
- Install exited **0** with `--frozen-lockfile` (lockfile is
self-consistent; no drift).
- Exactly one `eventsource` entry in the store, and it is the patched
one:
`node_modules/.pnpm/eventsource@3.0.7_patch_hash=427032a8df76e38f39988ff5fb919a02ccb70eb8a235e2b484007e4bebb1e67e/`
- Resolved `package.json` in the store after clean install:
`{"deno":"./dist/index.js","source":"./src/index.ts","import":"./dist/index.js","require":"./dist/index.cjs","default":"./dist/index.js"}`
— `bun` absent, everything else intact.
- Lockfile records it deterministically:
`patchedDependencies.eventsource@3.0.7` with `hash: 427032a8...` and
`path: patches/eventsource@3.0.7.patch`, and the dependency edge
resolves as `eventsource@3.0.7(patch_hash=427032a8...)`.
- `--frozen-lockfile` accepted the lockfile verbatim (it does not
rewrite), so the lockfile is self-consistent with the manifests.
- The comment header on the patch file does not break pnpm's patch
applier.
- **Lockfile diff is scoped to eventsource — 9 lines, 3 hunks, nothing
else.** An earlier revision of this branch carried incidental drift
(`vue-component-type-helpers` 3.3.8→3.3.9 and a `vite` peer-range
narrowing) picked up by a non-frozen install; that has been reverted so
the diff contains only the patch wiring.
## Tests
All from `packages/runtime`, with the patch applied:
| Suite | Command | Result |
|---|---|---|
| Full runtime suite | `pnpm exec vitest run` | **130 files / 1835 tests
passed**, 0 failed |
| Node integration (other CI leg) | `pnpm exec vitest run
src/v2/runtime/__tests__/integration/node-servers.integration.test.ts` |
**153 passed** |
| MCP + SSE transport | `pnpm exec vitest run
src/agent/__tests__/mcp-servers-integration.test.ts
src/agent/__tests__/mcp-clients.test.ts
src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts` | **3
files / 22 passed** |
| Bun integration | `bun test .../bun-servers.integration.test.ts` |
**20/20** (was 5/20) |
Non-Bun consumers are unaffected by construction — Node never reads the
`bun` export condition — and the Node suites above confirm it. The SSE
path stays covered: `mcp-servers-integration.test.ts` exercises
`mcpServers: [{ type: "sse", url }]`, so it executes the new `await
import()`, which sits **outside** the `try/catch` that swallows
per-server connection failures.
## Module-graph proof for commit 3
Commit 3 is hygiene, so it gets its own narrower proof. Probe: Bun
populates `require.cache` with the resolved path of every module
actually loaded, so importing one module and inspecting that cache shows
whether `eventsource` entered the graph. Two controls run every time so
it can't pass vacuously.
```ts
const target = process.argv[2]!;
await import(target);
const keys = Object.keys(require.cache).filter(
(k) => /eventsource/.test(k) && !/eventsource-parser/.test(k),
);
console.log(`${target}\n eventsource loaded: ${keys.length > 0 ? "YES" : "NO"}`);
```
| Module | before commit 3 | after commit 3 |
|---|---|---|
| `@copilotkit/shared` (negative control) | NO | NO |
| `@modelcontextprotocol/sdk/client/sse.js` (positive control) | YES |
YES |
| `../src/agent/index.ts` (subject, non-SSE path) | **YES** | **NO** |
Both controls hold steady; only the subject flips. Measured on its own,
commit 3 does **not** move the bun pass rate (5/20 before, 3/20 after
within noise) — which is exactly why commit 2 exists.
## Typing
No `as any`, no `@ts-ignore`. `const { SSEClientTransport } = await
import(...)` keeps the class fully typed — TypeScript resolves
dynamic-import types statically. `packages/runtime/tsconfig.json`
already sets `"module": "es2022"` with the comment *"so dynamic import()
typechecks"*, so the pattern is anticipated.
Two adjacent bare `let` declarations (`transport`, `mcpClient`) gained
explicit annotations (`MCPTransport | undefined`, `MCPClient`) because
editors surface them as implicit-any suggestions. Both pre-existed on
`main`. Verified: `tsc --noEmit` clean; `tsc --noEmit --strict` error
set **identical to baseline** (3 pre-existing unrelated `TS2769`s);
`oxlint` warnings **unchanged from baseline** (2, both pre-existing).
`SSEClientTransport` is `@deprecated` in SDK 1.29.0 in favour of
`StreamableHTTPClientTransport`. That deprecation pre-exists on `main`
and is left alone: `type: "sse"` is documented public config, SSE and
Streamable HTTP are different wire protocols, and the SDK's own note
says clients "may need to support both transports during the migration
period." Migrating is a user-facing change for its own PR.
## Gates run
- `pnpm exec oxfmt --check packages/runtime/src/agent/index.ts` — clean
- `pnpm exec oxlint packages/runtime/src/agent/index.ts` — 0 errors, 2
warnings (both pre-existing on `main`)
- `pnpm nx run @copilotkit/runtime:check-types` — pass
- `pnpm exec commitlint --from HEAD~3 --to HEAD` — pass
- `pnpm install --frozen-lockfile` from a fully wiped workspace — exit 0
Closes the `openUrl` XSS reported against `@a2ui/web_core`
([GHSA-72qq-p3r5-f7wq](https://github.com/a2ui-project/a2ui/security/advisories/GHSA-72qq-p3r5-f7wq),
CVSS 9.3).
## The vulnerability
`@a2ui/web_core` <= 0.10.1 passed an agent-supplied `openUrl` argument
straight to `window.open()` with no scheme allowlist:
```js
// basic_catalog/functions/basic_functions.js — 0.9.0
if (args.url && typeof window !== 'undefined' && window.open) {
window.open(args.url, '_blank'); // no scheme check
}
```
A malicious agent could emit a Button whose `functionCall` named a
`javascript:` URI; clicking it executed arbitrary script in the host
application's origin. The Basic Catalog is the default, so no
non-default configuration was required to be exposed.
## Why it reached us
We pinned `0.9.0` **exactly**, as a runtime `dependencies` entry of two
published packages — so downstream users could not upgrade out of it
without an `overrides` entry:
| Published package | Path to the vulnerable version |
|---|---|
| `@copilotkit/a2ui-renderer` | direct pin `0.9.0` |
| `@copilotkit/vue` | direct pin `0.9.0` |
| `@copilotkit/react-core` | → `a2ui-renderer` |
We import the sink deliberately (`BASIC_FUNCTIONS`) in four places
across the React, Lit, and Vue catalogs, and add no sanitisation of our
own. Note the advisory enumerates three affected renderers (React, Lit,
Angular); we ship a fourth, Vue, that upstream did not list.
## The change
Bump to `0.10.4`, which adds a strict http/https allowlist plus
`noopener,noreferrer`. This is a drop-in upgrade: `0.10.4` still exports
the `./v0_9` and `./v0_9/basic_catalog` entrypoints we import, and of
the `v0_9` surface (165 → 194 exports) the only symbol removed is
`FrameworkSignal`, which is referenced nowhere in this repo.
`showcase/angular` and `examples/v2/angular/demo` are private; those
bumps are hygiene only.
## Tests
Adds 6 regression tests over the React and Lit renderers, which reach
the sink independently of each other. They assert that `javascript:` and
`data:` URIs never reach `window.open`, that https URLs still open with
`noopener,noreferrer`, and that a blocked scheme leaves the surface
mounted rather than escaping into the click handler.
These were verified to be non-vacuous: pinned back to `0.9.0`, **5 of
the 6 fail**, including direct confirmation that `javascript:alert(1)`
reaches `window.open` through our own renderer on the vulnerable
version.
Full suites green: `a2ui-renderer` 22, `vue` 1074, `react-core` 1471,
`runtime` 1835, `angular` 292, `react-native` 251. Builds and
type-checks clean.
## Behaviour change worth knowing
`0.10.x` changes `openUrl`'s failure mode: the old code silently no-op'd
on a bad URL, the patched one throws `A2uiExpressionError` for a
non-http(s) scheme. That throw does **not** reach the render path —
`web_core`'s own `evaluateFunctionReactive` already catches it and
routes it to `surface.dispatchError`. Confirmed by exercising a real
click in both renderers: nothing escapes, no uncaught error, the surface
stays mounted.
## Follow-ups (not in this PR)
- **`@copilotkit/angular@0.3.0` remains exposed.** It pins
`@copilotkit/a2ui-renderer@1.63.2`, which pins the vulnerable `0.9.0`.
It needs a release after `a2ui-renderer` publishes, or Angular users
stay on the vulnerable transitive.
- **Blocked actions are invisible.** The resulting `EXPRESSION_ERROR` is
emitted on `surface.onError`, which no renderer subscribes to — so an
agent probing `javascript:` URIs is blocked completely silently, with no
log or telemetry. Surfacing it is a cross-renderer API decision,
deliberately kept out of a security bump.
GHSA-72qq-p3r5-f7wq (CVSS 9.3). web_core <= 0.10.1 passed an agent-supplied
`openUrl` argument straight to `window.open()` with no scheme allowlist, so a
Button whose `functionCall` named a `javascript:` URI executed arbitrary script
in the host origin when a user clicked it. The Basic Catalog is the default, so
no non-default configuration was required to be exposed.
We pinned 0.9.0 exactly, as a runtime dependency of two published packages
(@copilotkit/a2ui-renderer, @copilotkit/vue) and transitively of
@copilotkit/react-core and @copilotkit/angular, so downstream users could not
upgrade out of it on their own. 0.10.4 keeps the ./v0_9 and
./v0_9/basic_catalog entrypoints we import; the only symbol dropped from v0_9
is FrameworkSignal, which we never referenced.
Add regression tests over both renderers that reach the sink independently
(React and Lit). They assert that javascript: and data: URIs never reach
window.open, that https URLs still open with noopener,noreferrer, and that a
blocked scheme leaves the surface mounted rather than escaping into the click
handler. Verified they fail against 0.9.0 and pass against 0.10.4.
Module-graph hygiene, not a behaviour fix -- the preceding eventsource patch is
what fixes the bun failure.
The SDK's SSE transport was imported at the top of the agent module but is only
constructed inside the `type === "sse"` branch ~1300 lines below, so
`eventsource` was pulled into the module graph of every non-SSE path, including
every test that merely touches the agent module. Move it to an `await import()`
at the point of use.
`transport` and `mcpClient` gain explicit annotations so they keep real types
instead of the bare `let` declarations they had before.
## Problem
Channel agents lose inbound files whenever the current-trigger
transcript omits attachments. The currently deployed Intelligence path
always omits those files because normalized_payload never contains their
handles.
## Why
The delivery adapter seeds the current inbound turn from the transcript,
and core then deduplicates the explicit prepared input. Files present
only on the prepared delivery therefore never reach either the
agent-history consumer or channel.getMessages during version skew.
## Fix
Restore a missing current-trigger transcript file list from the prepared
delivery inside ClaimedChannelDelivery.getTranscript(), where the result
is shared and memoized for both consumers. Existing transcript files are
preserved, so the Intelligence fix and this fallback cannot duplicate
attachments.
Either PR independently repairs the agent path. Coverage proves both an
omitted transcript and an already-correct transcript hydrate the image
for getMessages and agent seeding.
## Summary
- add `defineChannelComponent` so an agent can call a server-rendered
JSX component as a typed tool
- add native JSX namespaces for Slack Block Kit and Teams Adaptive Cards
- use one provider codec for both direct adapters and
Intelligence-managed delivery
- recover interactive handlers by stable JSX key after a process restart
- generate and audit the native component catalog against the provider
catalogs
This keeps native provider UI in the existing Channels render and
delivery path. It does not add a second renderer, transport, or action
system.
## Review order
1. **Component tool contract:**
`packages/channels-core/src/channel-component.ts`, `create-channel.ts`,
and `thread.ts`
- Standard Schema validates agent arguments before render.
- Render receives the source platform and run `AbortSignal`.
- The rendered UI posts as a separate provider message; the tool returns
a short acknowledgement.
2. **Native IR:** `packages/channels-ui/src/native.ts` and `render.ts`
- Native nodes carry a provider tag.
- Traversal follows named slots such as Slack `accessory` and Teams
`actions`, not only `children`.
3. **Slack:** `packages/channels-slack/src/native*.ts`, `render.ts`, and
`interaction.ts`
- `Slack.Block`, `Slack.Element`, and `Slack.Object` map to Block Kit
field names.
- Direct and managed Slack share the same codec and fallback-text rules.
4. **Teams:** `packages/channels-teams/src/native*.ts`,
`render/index.ts`, and `interaction.ts`
- `Teams.AdaptiveCard` is the explicit root.
- The serializer computes the minimum Adaptive Card version from every
type and property used.
5. **Recovery and managed parity:**
`packages/channels-core/src/action-*.ts` and
`packages/channels-intelligence/src/delivery-adapter.ts`
- Stable JSX keys, the source platform, and the action value are stored
in the action snapshot.
- CopilotKit/Intelligence#729 asserts the final Slack Web API and Bot
Framework request bodies.
## Data flow
```text
agent tool call
-> Standard Schema validation
-> async JSX render
-> portable or provider-native Channel IR
-> Slack or Teams codec
-> direct adapter or Intelligence-managed delivery
-> provider API
```
```text
provider interaction
-> provider callback decoder
-> hot ActionRegistry lookup
-> ActionStore snapshot fallback
-> component re-render
-> stable keyed handler
```
## Public API
An agent-rendered component uses the same JSX vocabulary as
`thread.post`:
```tsx
const Approval = defineChannelComponent({
name: "show_approval",
description: "Post an approval request.",
parameters: z.object({ title: z.string() }),
render: ({ title }, { platform }) => (
<Card title={`${title} (${platform})`}>
<Button key="approve" value="approve" onClick={approve}>
Approve
</Button>
</Card>
),
});
createChannel({
name: "approvals",
components: [Approval],
});
```
Use native JSX only when the portable vocabulary does not expose a
provider feature:
```tsx
await thread.post(
<Slack.Block.Section
text={<Slack.Object.MarkdownText text="*Deploy ready*" />}
accessory={
<Slack.Element.Button
key="approve"
text={<Slack.Object.PlainText text="Approve" />}
value={{ decision: "approve" }}
onClick={({ action }) => approve(action.value)}
/>
}
/>,
);
```
```tsx
await thread.post(
<Teams.AdaptiveCard fallbackText="Deploy approval">
<Teams.TextBlock text="Deploy ready" wrap />
<Teams.ActionSet>
<Teams.Action.Submit
key="approve"
title="Approve"
value={{ decision: "approve" }}
onSubmit={({ action }) => approve(action.value)}
/>
</Teams.ActionSet>
</Teams.AdaptiveCard>,
);
```
## Guardrails
- Native nodes from one provider fail if rendered for another provider.
- Slack rejects missing required fields, invalid top-level nodes, and
more than 50 blocks.
- Teams rejects invalid roots and explicit versions below the minimum
required version.
- Interactive nodes in agent-rendered components require stable, unique
JSX keys.
- `Slack.Raw` and `Teams.Raw` accept reviewed provider JSON but do not
bind callbacks.
The generated catalog is in `packages/channels/native-catalogs.md`. The
package READMEs contain the full Slack, Teams, and component-tool usage
notes.
## Test map
| Contract | Main coverage |
| --- | --- |
| component tool schema, render context, post, and acknowledgement |
`packages/channels-core/src/channel-component.test.ts` |
| native IR, provider tags, and named-slot traversal |
`packages/channels-ui/src/native.test.tsx` |
| Slack catalog, serialization, validation, and callbacks |
`packages/channels-slack/src/native-*.test.*` |
| Teams catalog, versioning, serialization, and callbacks |
`packages/channels-teams/src/native-*.test.*` |
| keyed cold recovery and reaction recovery |
`packages/channels-core/src/*recovery.test.*` |
| managed codec parity |
`packages/channels-intelligence/src/delivery-provider-elements.test.ts`
and CopilotKit/Intelligence#729 |
## Validation
- `pnpm nx run-many -t test,check-types,build
--projects=@copilotkit/channels-ui,@copilotkit/channels-core,@copilotkit/channels-slack,@copilotkit/channels-teams,@copilotkit/channels-intelligence,@copilotkit/channels`
- `pnpm verify:channels-umbrella`
- `pnpm check:channel-native-catalogs`
- `pnpm audit:channel-native-catalogs`
- pre-commit tests, publint, and API Extractor checks for 27 affected
projects
- all PR checks pass on `b88f9b7e4f247939d65603d96df26090baad916a`
## Summary
Document the draft-first Microsoft Teams setup flow across Channels
docs, skills, and the Teams adapter README.
## Why
Intelligence now offers a resumable Fast CLI path and a Guided manual
path while keeping custom branding artifacts local and separating
provider completion from runtime health.
## How
- Describe the fully scoped provisioning and resume contract.
- Replace Azure Bot and manifest-editing guidance with Teams Developer
Portal plus Entra.
- Teach both setup skills the local-only artifact and Team-installation
boundaries.
- Update documentation contract tests for the new path.