feat(channel): migrate Intelligence channel host to CopilotKit v2 managed-channels (channels 0.2) - #10
feat(channel): migrate Intelligence channel host to CopilotKit v2 managed-channels (channels 0.2)#10jerelvelarde wants to merge 50 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…API) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nel rename set) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…te-opentag) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…managed-channels runtime
- app/index.ts: self-hosted createBot->createChannel (behavior unchanged)
- app/managed.ts: rewrite to createChannel + CopilotRuntime({channels}) + createCopilotNodeListener; drop startChannelsOverRealtimeGateway + org/project/channel-id env
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…doc/comment cleanup Now transitive via @copilotkit/channels + @copilotkit/runtime; v2 managed.ts no longer imports it directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
managed.ts: await channels.ready() + exit-1 on activation reject; server 'error' handler; blank INTELLIGENCE_CHANNEL_NAME normalized; extension-agnostic entry guard. IaC/docs: INTELLIGENCE_API_URL/GATEWAY_WS_URL now preserve() (drift + dev-leak); .railway added to tsconfig. commands: shared safely()/safePost() guards for bare postEphemeral/openModal/post. HITL: confirm_write detail capped; file-issue log prefix. Slack char-budget clamps: issue-list count, showcase fields/links, render-table monospace fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…essions showcase-tools: clampWithinBudget keeps >=1 item (show_links empty-Section crash on a single over-budget link); show_incident summary .min(1) + Section guarded; MAX_LINKS budget 3000->2800 (margin parity + mrkdwn escape-expansion). render-table: sanitize embedded newlines in monospace fallback (fixes column alignment + split-based shownRows accounting). Comment accuracy: managed.ts brain framing (agent/ ships), tools/index tool-order, HITL 'later wave' marker, .railway port-pinning invariant scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t/diagram rendering The channel host runs the Playwright-based render_chart/render_diagram tools; the IaC never installed Chromium, so those failed on the one-click deploy. Add a build step (playwright install --with-deps chromium), PLAYWRIGHT_BROWSERS_PATH=0 so the browser ships in node_modules, and RAILPACK_DEPLOY_APT_PACKAGES for the runtime libs — mirrors the kite reference deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviewing PR #8 (feat/intelligence-adapter) alongside this branch surfaced three defects on the managed path. All three are things the un-run live gateway check would have caught. 1. Dropped instruction on attachment turns. `promptFromMessage` returned `contentParts ?? text`, but channels-core's `msgFromTurn` keeps the turn's text and its attachment parts in SEPARATE fields — parts never fold in the instruction. So "chart this" + a CSV reached the model as a bare data dump and came back "what would you like me to do?". Lead with the instruction, then the parts. PR #8 hit and fixed this on the sibling transport; the fix is ported from there. A test asserted the broken shape — replaced. 2. Unregistered interactive components. Nothing was passed to `createChannel({ components })`, so on the managed path — where a click arrives as a fresh delivery, with no in-process handler cache — a click cannot be resolved and dead-letters. That silently strands `ConfirmWrite`, the HITL gate on every write. Register it plus `IncidentCard`, and add a source-scan test so a future interactive card cannot be forgotten. 3. Unauthenticated HTTP surface. `createCopilotNodeListener` publishes the whole v2 route set (agent/:id/run, threads/*, memories/*, transcribe) with no auth, and this host registers `agents.kite` pointed at AGENT_URL — an open proxy to the brain and to thread history for anyone who can reach the port. Gate it via `hooks.onRequest`: closed (404) unless CHANNEL_HTTP_TOKEN is set, bearer-gated (401) when it is, constant-time compare. Verified against a real listener: 404/404/404 closed, 401/401/pass gated. pnpm check-types: 0 errors. pnpm test: 122 passing (was 116). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`.migration-discovery.md` was a scratch artifact sitting at the repo root, ungitignored, and contradicted by the PR that carried it: it records the runtime floor staying at ^1.62.3 and channels-intelligence as a direct dep, while the shipped branch raises the floor to ^1.63.2 and drops that dep. Its 571 lines of verified SDK signatures are worth keeping, so move it beside the design doc and plan under docs/superpowers/specs/ (the convention PRs #6 and #7 established) and head it with a note marking it a point-in-time record plus the two places the implementation deliberately diverged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review — v2 managed-channels migrationScope: the PR diff only (38 files, +2659/−442). I verified the SDK contracts this PR depends on against the published OverviewReplaces the deprecated The migration itself is sound. Confirmed against the SDK types that Blocking: the success log can still lie
Confirmed in await listener.channels?.ready?.({ timeoutMs: 30_000 });
const health = listener.channels?.status();
if (health && health.overall !== "online") {
console.error(`[channel] channel not live: ${JSON.stringify(health.channels)}`);
process.exit(1);
}Should fix
Nits
What's goodThe HTTP auth gate is the strongest part of the PR: default-404 (not 403) is the right posture for a surface nobody should be probing, the On the #8 overlap: that is a coordination call, not a code question — but this is the branch based on current |
…he closeServer test closeServer now calls closeAllConnections() after close() so an open SSE agent-run stream at SIGTERM can't pin shutdown until the platform SIGKILLs the container (skipping closeBrowser + process.exit). The prior happy-path test set `closed = true` before invoking the callback, so it would pass even if the implementation ignored the callback entirely; it's replaced with a test that asserts the promise stays pending until close() actually reports done, plus a new test for the closeAllConnections call.
…und browser close Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ants Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…den its restart budget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
closeBrowser() ended with two empty catch blocks that swallowed both a Chromium launch failure and a close() failure, making the .catch handler in managed.ts's shutdown path unreachable dead code. Now both failure modes propagate so the operator gets a log line instead of a silently orphaned browser, while the "never launched" case stays a clean no-op and repeated calls stay safe.
…rt-precedence claims
…ed failure modes
The scan only recognized `export function`/`export const` and attributed an
onClick to whatever export it last saw, so `export default function`,
`export async function`, and bare declarations re-exported via `export { }`
were invisible (fails open — a real interactive component reports as
all-clear), while a `defineChannelTool({...})` config or a string constant
sitting near real onClick markup got wrongly flagged for registration
(fails closed — something unregistrable is demanded).
Strips comments before scanning so a mentioned-in-prose `onClick=` never
counts, widens the declaration matcher to cover `export default function`,
`export async function`, and `export default async function`, tracks
`export { Foo }` re-exports of bare declarations, and — the core of the
fix — throws instead of silently returning `[]` when a real onClick can't
be attributed to any eligible component-shaped declaration.
`interactiveComponentNames` gains an optional `label` parameter (defaulting
to `"<source>"`) included in thrown messages.
…the module import-safe
Four ways app/managed.ts violated its own "fail fast, never sit in a
half-working state" contract:
- required() used `!v`, which lets whitespace-only env values (e.g.
AGENT_URL=" ") boot green and then fail every turn at runtime instead of
at boot. Trim and reject blank, same as channelName/CHANNEL_HTTP_TOKEN
already do. AGENT_URL is also now validated as a parseable URL.
- buildAgentHeaders shipped a whitespace-only AGENT_AUTH_HEADER as a real
Authorization header on every agent request; now trimmed and treated as
absent.
- unhealthyChannels only looked at the per-channel status map, which
ChannelManager.status() returns empty before activation settles
(`{overall: "connecting", channels: {}}`) and after stop()
(`{overall: "stopped", channels: {}}`) — so the boot gate logged
"(channel live)" for a channel that never activated. It now folds in
`overall` when the per-channel map has nothing to say.
- process.on("unhandledRejection"/"uncaughtException") and
`import "dotenv/config"` ran at module scope, firing on every import —
including managed.test.ts's import for the pure helpers, installing global
handlers into the vitest worker and loading the developer's local .env into
the test process. Both now live behind the existing entry-point guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tuck channels, flush diagnostics Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rors through teardown, harden its tests
onceShutdown: a synchronously-throwing run escaped the inFlight memo
(inFlight ??= run(...).catch(...) never assigns if run() throws before
returning), leaking the exception into the signal handler and letting the
next signal start a second, competing run. Wrap the call in a try/catch so a
sync throw becomes a rejected promise BEFORE .catch is attached, joining the
same memoized promise with identical microtask timing as a rejection (an
async-IIFE wrapper also catches the throw but adds an extra microtask hop,
which broke an existing timing-sensitive test). Also corrected the docstring:
later signals are silent no-ops, not "attached" observers of the first run.
server.on("error"): the non-stopping branch called fatal() directly for a
live post-bind socket error, bypassing channels.stop()/closeBrowser() the
same way the watchdog's fatal path did before an earlier pass routed it
through runShutdown. Route the post-bind case through runShutdown(...,1) too;
the pre-bind (bind failure, listening===false) case still exits directly via
fatal() since there's nothing to tear down.
Hardened three onceShutdown assertions that could not fail against the
mutants they were meant to catch: the "settled" check used one microtask
tick instead of a real macrotask boundary; the unhandled-rejection test only
asserted a synchronous function doesn't throw; and the onError guard threw
from inside an unawaited .catch, so it could never fail its own it(). Fixed
by using a real setTimeout boundary, a process.on("unhandledRejection") spy,
and an array of recorded onError calls, respectively.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ms; state the Slack provider Three MANAGED_COMPONENTS/closeServer/activation docstrings asserted SDK/Node behavior that installed source (@copilotkit/channels-core, @copilotkit/ channels-intelligence, @copilotkit/runtime/v2, Node's net.Server) contradicts: component registration is insurance for a durable store, not a live dead-letter guard; server.close()'s callback-less error was never going to reach the 'error' event; mounting the listener only constructs the channel manager, activation is lazy on channels.ready(). Also make the implicit Slack provider default explicit on createChannel, and rewrite the onceShutdown exitCode test so its "onError must not fire" claim is an assertion instead of a throw that only ever surfaces as an unrelated unhandled rejection.
…own routines need
…d missing env vars Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nstead of regexes
Replaces the regex-based interactive-component scanner backing the
MANAGED_COMPONENTS guard with a real ts.createSourceFile walk. A
7-agent review round independently confirmed six fails-open modes
(a type annotation, a generic arrow, memo(...), a greedy RHS peek
swallowing the next declaration, a `//` inside a string literal, and
spaced `onClick =`) and three fails-closed modes (`export default Foo;`,
an exported lowercase factory, and a template literal containing text
that looked like a declaration) that text-level parsing could not fix.
The walk climbs from each JSX `onClick` attribute to its nearest named
enclosing declaration (function or const, any RHS shape) using real AST
containment instead of textual spans, so attribution can no longer leak
across sibling declarations. A declaration is only ever reported if
exported (directly, via `export { Foo }`, or via `export default Foo;`);
among exported declarations, only capitalized names are treated as
components, which is what excludes `defineChannelTool(...)` descriptors
and lowercase factory functions without having to model what they do.
An onClick that can't be attributed to any named declaration, or that
lands in an unexported one, still throws — the fail-loud property is
preserved, not regressed.
Also deletes the "mutant proof" test block that re-implemented the old,
already-deleted regex: it could never fail in response to a change to
the shipped scanner and only diluted the suite's signal.
Verified by running the new scanner over the real app/**/*.tsx tree
(excluding __tests__/.test.), which returns exactly
["ConfirmWrite","IncidentCard"] with no throw.
…dead browsers closeBrowser() now sets `closing` before its early return, so a shutdown with nothing yet launched can no longer race a concurrent getBrowser() into starting (and orphaning) a fresh Chromium. A rejected launch is cleared from the cache instead of being remembered forever, so one transient launch failure no longer permanently disables rendering; the next getBrowser() retries and the failure is logged once. A `disconnected` listener clears the cache when Chromium crashes on its own, so a dead browser is replaced instead of failing every render forever with Playwright's "Target closed".
…is online, validate URL scheme
Seven correctness findings in app/managed.ts:
1. server.listen's async callback had no terminal handler, so a throw
after channels.ready() (status check, channel.name getter, setInterval,
watchdog.unref()) became an unobserved rejection -- socket bound, no
watchdog, no exit. Wrap it in a tracked IIFE with a .catch that routes
to fatal(), respecting `stopping`.
2. unhandledRejection logged and kept running while uncaughtException was
fatal, on the premise that nothing demonstrated the hazard -- Finding 1
is that demonstration. Moved the handler inside main() (it needs
`stopping`) and reused uncaughtExceptionAction so both handlers share
one policy.
3. computeOverall([]) returns "online", so an empty channels map read as
healthy -- and a test blessed that shape. unhealthyChannels now takes
the declared channel's name and asserts it is present and online,
rather than inferring health from an empty per-channel map.
4. requireUrl accepted anything new URL() parses, including scheme-less
values ("agent:8123") and non-HTTP schemes. Now requires http:/https:
and a non-empty host.
5. The watchdog's degradedSince reset on a missing health reading,
treating "no data" as "recovered". Extracted the pure nextDegradedSince
helper: only an affirmative overall === "online" resets the clock.
6. onceShutdown assigned its memo after invoking run, so a run that
synchronously re-enters the returned function could start a second
teardown. Set a placeholder guard before invoking run.
7. promptFromMessage prepended a blank text part for whitespace-only
text (" " is truthy). Now checked via .trim().
Findings 1, 2, and part of 5 (the setInterval callsite) live inside
main() and are not unit-testable without restructuring main(), which is
out of scope for this pass -- verified by inspection instead. Findings 3,
4, 6, 7, and the extracted nextDegradedSince helper (5) are covered by
new red-green tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raded clock, name findings by behavior
Correct two false claims about SDK internals in app/managed.ts: the
provider:"slack" default lives in @copilotkit/runtime's
channel-activation-config.mjs (deriveChannelActivationConfig), not in
channels-core's create-channel.d.ts, which only documents the closed
union and omits the key entirely when unset. And ChannelManager.status()
after stop() does not clear entries to {} — it flips each entry's status
to "stopped" — so the post-stop shape is
{ overall: "stopped", channels: { "kite-opentag": "stopped" } }, caught
by the per-channel filter directly, not the overall fold. Fixed a test
fixture in managed.test.ts that encoded the impossible empty-map
post-stop shape.
Also: describe unhealthyChannels' actual fold-in condition (broader than
"the map is empty") and add the missing test for a degraded overall
alongside an all-online map; add the nextDegradedSince test block the
prior pass extracted the helper for but never wrote (red-green verified
the undefined-preserves-the-clock case); pass the missing filename to
interactiveComponentNames in the app-tree scan so a CI failure names a
file; and reword "Finding N" review-process labels in comments and test
names to describe the behavior they guard instead.
Comment/test-only: no executable-line changes in app/managed.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ling integrations
…s, honour export aliases
…gate and the watchdog
Liveness was defined three different ways: the boot gate checked the
declared channel by name, the watchdog and the degraded-clock policy
each branched on raw `overall`. They disagreed on the exact shape the
boot gate was hardened to catch — `{ overall: "online", channels: {} }`,
which ChannelManager.computeOverall genuinely returns for zero-length
input — leaving the watchdog permanently quiet and the degraded clock
reset every tick for a host whose declared channel silently dropped out
of the manager's entries.
Extract the single predicate (`isChannelLive`) and make unhealthyChannels,
channelWatchdogTick, and nextDegradedSince all consult it. Also: report
the declared channel's real condition (`name=absent (overall=...)`)
instead of the self-contradicting `overall=online`; make the by-name
presence check unconditional so it isn't buried behind an unrelated
channel's bad status; make the setup_required remediation advice
conditional on that status actually applying (new `notLiveMessage`); and
replace ChannelHealth's widened `string` fields with the SDK's closed
ChannelStatus union so a renamed status fails typecheck instead of
silently disabling the comparison.
A missing health reading now escalates after CHANNEL_DEGRADED_FATAL_MS
instead of staying quiet forever, matching nextDegradedSince's existing
"no reading != recovered" stance.
…stent
Six fail-loud gaps in app/managed.ts where an exit path could silently not
exit, or exit inconsistently with its siblings:
1. fatal() called fs.writeSync unguarded — a non-blocking pipe-backed fd 2
throws EAGAIN under backpressure, and the throw escaped past
process.exit entirely. Extracted guardedWrite() (swallow-and-continue,
the one correct empty catch in this file) so the write can never
suppress the exit that follows it.
2. The entry-point guard compared a realpath-resolved import.meta.url
against an unresolved process.argv[1], so running via a symlink made
the guard silently fail closed: main() never ran, process exited 0,
zero output. Resolve argv[1] through realpathSync before comparing.
3. runShutdown was referenced (via server.on("error") and the watchdog
interval) before its const declaration further down main() — safe only
because nothing awaits in between today. Hoisted shutdown/runShutdown
above every referencing call site so the TDZ hazard is structurally
impossible, not just accidentally avoided.
4. unhandledRejection was blanket-fatal for anything not mid-shutdown, on a
justification (the listen callback's unobserved promise) that's now
obsolete since that IIFE has its own terminal .catch. A recurring
library-level stray rejection (Slack SDK, Playwright, Phoenix WS,
SanitizingHttpAgent) could burn the whole restart budget and leave the
service stopped. New unhandledRejectionAction only escalates to fatal
when the rejected Error's stack names this host's own module; anything
else logs without exiting.
5. The notLive boot gate and the ready()-rejection path called fatal()
directly even though channels.ready() already ran activate() by the
time either fires — leaking the gateway lease instead of getting a
clean disconnect, unlike the watchdog/server-error paths which were
already routed through runShutdown for exactly that reason. Routed both
(and the boot IIFE's outer catch-all, for the same reason) through
runShutdown too.
6. shutdown()'s browser-cleanup log used plain console.error immediately
before process.exit — the exact truncation hazard fatal() exists to
avoid, and the only signal an operator gets that Chromium was orphaned.
Routed it through the same guardedWrite() fatal() uses.
Findings 2, 3, and 5 are structural (entry-point ordering, TDZ hazard,
main()'s control flow) and aren't unit-testable without restructuring
main() itself — verified by inspection and full-suite regression instead.
Findings 1, 4, and 6 extract pure decisions (guardedWrite,
unhandledRejectionAction) that are directly unit tested, including RED
reproductions of each original bug.
245 -> 251 tests, 15 files, 0 type errors.
…ORT and scheme parsing
Final pass on app/managed.ts: requireUrl's dead !url.hostname check
(unreachable once the protocol is narrowed to http/https — new URL()
throws on an empty host for those schemes instead) is replaced with a
check for unresolved deployment template syntax ($, {, }, whitespace) in
the hostname, which is the actual Railway failure mode (an unresolved
${{service.VAR}} reference parses cleanly and fails every turn deep
inside SanitizingHttpAgent instead of at boot). httpAuthGate now compares
the Authorization scheme case-insensitively per RFC 7235, preserving the
constant-time credential compare. PORT parsing is extracted into a pure,
tested parsePort() that rejects hex/exponential/decimal-point input
Number() silently accepted. promptFromMessage now emits the trimmed text
instead of the raw padded value. The boot success log and channel-name
resolution in main() now thread a single normalizeChannelName() result
instead of re-deriving it from channel.name after construction.
Review-fix cycle complete — 38 commits pushedFollowing the review above, I ran an implementation plan plus three rounds of unbiased 7-agent code review with fix cycles between them. Final state: 280 tests / 15 files, 0 type errors, The honest headline: round 1 found ~30 must-fix findings, round 2 found ~30 more — a large share created by round 1's own fixes — and round 3 found ~20. Severity dropped each round and the test suite got materially stronger (mutation testing went from catching almost nothing to 20 of 24 mutants), but no round reached zero. What follows is what landed, what didn't, and why. The single most consequential findingRailway's SIGTERM→SIGKILL grace period defaults to 0 seconds. Fixed by My original dispatch instruction here said "neither Fail-loud holes closed
The interactive-component guardThe Also worth stating plainly: a reviewer traced the SDK and found registration is currently neither necessary nor sufficient — Tests that couldn't failSix tests in this cycle passed against the exact implementations they were written to reject. Each was rewritten and then proven by applying the named mutant and watching it fail:
Root cause, per the convergence audit: the assertion sat outside the channel the test runner observes. Also removed a ~90-line "mutant proof" block that tested a local copy of a regex no longer in the repo — it could never fail in response to any change to the shipped scanner. I had praised it as a durable artifact; it was history dressed as a guard. Docs and citationsSeveral docstrings asserted SDK or Node behavior that was wrong, including three I wrote:
Also: The Chromium apt list was derived from Playwright's own native-deps table (found bundled in Deliberately not fixedOne accepted asymmetry
Follow-up PRs (real, load-bearing, different subject)
Structural levers (deferred by scope decision)The convergence audit grouped the findings into 8 root-cause classes and proposed additive levers — wiring-capture tests (asserting Subject-neutral backlog
Not verifiedNo live gateway run, and no deploy. Everything above is static analysis, unit tests, and SDK/Node source reading. The operator steps from the original plan remain outstanding: set The 🤖 Generated with Claude Code |
Summary
Migrates OpenTag's Intelligence channel host (
app/managed.ts,pnpm channel) from the deprecated@copilotkit/channels-intelligencegateway launcher to the v2 managed-channels runtime, so it connects to a current CopilotKit Intelligence project (opentag-dev/ channelkite-opentag).The live Intelligence dashboard documents a runtime shape OpenTag didn't ship:
@copilotkit/runtime/v2(CopilotRuntime,CopilotKitIntelligence) +@copilotkit/channels(createChannel) +@copilotkit/runtime/v2/node(createCopilotNodeListener), with the channel declared by name and env reduced toINTELLIGENCE_API_URL+INTELLIGENCE_GATEWAY_WS_URL+INTELLIGENCE_API_KEY— no org/project/channel IDs (the runtime derives them from the credentials + channel name).The SDK typings back this shape:
createChannel({ provider })is documented as "the managed delivery provider a no-adapter Channel targets when activated via CopilotKit Intelligence." This PR builds exactly that — a channel with noadapters, activated when the node listener mounts.#8 (
feat/intelligence-adapter) is a second, independent implementation of this same migration. Both bump@copilotkit/channels*to0.2.1and rewriteapp/managed.ts. They conflict in 22 files with each other; #8 conflicts in ~15 withmain.#8 branches from
df93bc0, which predates both merged PRs #6 (Intelligence gateway + Pythonagent/) and #7 (Railway IaC) — so it has no knowledge ofagent/,.railway/, or thepnpm channel/pnpm agentscripts, and itspackage.jsondeletes the latter two.createChannel({ adapters: [intelligenceAdapter()] })CopilotRuntime({ channels })channel.start()COPILOTKIT_INTELLIGENCE_URL,COPILOTKIT_API_KEYINTELLIGENCE_API_URL/_GATEWAY_WS_URL/_API_KEYproviderdefaults to slack)df93bc0(pre-#6, pre-#7)mainBoth APIs still exist in 0.2.1, so neither is wrong — but #8's own
MANAGED.mddocuments it against a feature-flagged Intelligence branch (codex/hosted-bots-infra-vertical-slice,FF_MANAGED_BOTS=true), and the SDK calls that path "V1 Intelligence Channel exclusivity."#8 has real end-to-end Teams + Slack verification that this PR does not, and that's the one thing that can't be cherry-picked. Worth a conversation with @AlemTuzlak before either lands. Suggested split: land this as the base, then port #8's genuinely better pieces (listed at the bottom).
What changed
Dependencies
@copilotkit/channels*^0.1.1→^0.2.1;@copilotkit/runtimefloor^1.62.3→^1.63.2(where the v2channelsAPI +createCopilotNodeListenership).@copilotkit/channels-intelligencedep (still present transitively).Code
app/**migrated to the 0.2 API — mechanical rename:createBot→createChannel,BotTool→ChannelTool,BotCommand→ChannelCommand,defineBotTool→defineChannelTool,defineBotCommand→defineChannelCommand,Bot→Channel,BotNode→ChannelNode. Behavior unchanged.app/index.ts(self-hosted) migrated tocreateChannel; per-platform gating / handlers / shutdown preserved.app/managed.tsrewritten to the v2 managed-channels runtime:createChannel({ name: "kite-opentag" })→new CopilotRuntime({ agents, intelligence, identifyUser, channels })→createCopilotNodeListener; awaitslistener.channels.ready()(fail-loud, exit non-zero on activation error), adds a bind-errorhandler, and normalizes blank env.Config / docs
.env.example,.railway/railway.ts,README.md,setup.mdupdated to the v2 var set + runtime model..railway/railway.ts:INTELLIGENCE_API_URL/_GATEWAY_WS_URLare nowpreserve()(deployer-set, not clobbered byrailway config apply); thechannelservice installs Chromium (playwright install --with-deps chromium+PLAYWRIGHT_BROWSERS_PATH=0+ runtime apt libs) so inline chart/diagram rendering works on deploy;.railway/**added tocheck-types.Suggested review order
app/managed.ts— the only genuinely new file. Everything else is either mechanical rename or config.app/managed.test.ts— covers the three defects below..railway/railway.ts+.env.example— the deploy contract.git log -pper commit is cleaner than the squashed diff.Review history
Two rounds of unbiased 7-agent review with fix cycles, then a cross-review against #8 that caught three more defects this branch had on its own — all with covering red-green tests.
Caught by the earlier rounds:
managed.tsfail-loud regression — success was logged even when gateway activation failed.<Section>crashes (show_linkssingle over-budget link;show_incidentempty summary) and a monospace-fallback mis-count when a table cell contains a newline.Caught by the cross-review against #8 (commit
7f40b1d) — all three are things the un-run live check would have surfaced:promptFromMessagereturnedcontentParts ?? text, but channels-core'smsgFromTurnkeeps the turn's text and its attachment parts in separate fields — parts never fold in the instruction. "Chart this" + a CSV reached the model as a bare data dump. Fix ported from feat: managed (Intelligence-hosted) channel bot — Slack + Teams via @copilotkit/channels 0.2.1 #8, which hit this first on the sibling transport. A test asserted the broken shape; replaced.createChannel({ components }), so on the managed path — where a click arrives as a fresh delivery with no in-process handler cache — clicks can't resolve and dead-letter. That silently strandedConfirmWrite, the HITL gate on every write. Now registers it plusIncidentCard, with a source-scan test so a future interactive card can't be forgotten.createCopilotNodeListenerpublishes the whole v2 route set —agent/:id/run,threads/list,threads/messages,memories/*,transcribe— with no auth, and this host registersagents.kitepointed atAGENT_URL: an open proxy to the brain and to thread history for anyone who can reach the port. Now gated viahooks.onRequest— 404 whenCHANNEL_HTTP_TOKENis unset (default), 401 on missing/wrong bearer when it is, constant-time compare. Verified against a real listener:404/404/404closed,401/401/passgated.Testing
pnpm check-types: 0 errors (now includes.railway/).pnpm test: 122 passing (12 files), incl. red-green tests for every behavioral fix.INTELLIGENCE_*, runpnpm agent+pnpm channelagainstopentag-dev, confirmkite-opentagflips Waiting for runtime → live, then upload a CSV with "chart this" (exercises fix 1) and click through aconfirm_writegate (exercises fix 2). This PR does not deploy the channel to Railway.Deferred — pre-existing, unrelated to this migration
Kept out to preserve reviewability; filed as follow-ups.
app/index.ts: partial platform creds silently skipped + misleading "No platform secrets found"; whitespace-onlyPORTcrashes (vsmanaged.ts, hardened here).confirm-write-tool.tsx:awaitChoicerejection unwrapped (gate stays safe; error opaque).page-list.tsx: uncapped title/snippet (silent-truncation risk).render-chart/render-diagram:(e as Error).message→ "undefined" on non-Error throw; caption uses*x*(italic) not**x**(bold); misleading status when only the upload throws.issue-card/issue-list:.slice()can split an astral-plane character..env.example: presence-gated secrets ship non-blank placeholders → a copy-paste.envtreats them as configured..railway/railway.ts: builder pinning inconsistent across services.vitest.config.ts: dead esbuild JSX block — confirmed live, the run printsBoth esbuild and oxc options were set… esbuild options will be ignored.confirm_writebut docstring/setup imply a universal gate;NOTION_MCP_HOSTundocumented;render-tools.test.tsmisnamed; deadfindByTypehelper; imprecise "≤5 inputs" title.Worth porting from #8 afterwards
process.exit(0)immediately afterprocess.stdout.write(png)can truncate the PNG on a pipe, and the parent resolves that buffer with no length check; and theexecFiletimeout SIGTERMs the worker without a handler, sobrowser.close()never runs and Chromium orphans.render_chartschema coercions (numeric labels, quoted numbers, tolerantoptions).emoji: "refresh"on every provider).runner: new InMemoryAgentRunner({ onConcurrentRun: "supersede" })inruntime.ts.createChannel({ provider: "teams" }).Design + plan + discovery:
docs/superpowers/specs/2026-07-24-opentag-intelligence-v2-migration-design.md,docs/superpowers/plans/2026-07-24-opentag-intelligence-v2-migration.md,docs/superpowers/specs/2026-07-24-opentag-intelligence-v2-migration-discovery.md.🤖 Generated with Claude Code