Skip to content

feat(channel): migrate Intelligence channel host to CopilotKit v2 managed-channels (channels 0.2) - #10

Open
jerelvelarde wants to merge 12 commits into
mainfrom
jerel/opentag-intelligence-v2
Open

feat(channel): migrate Intelligence channel host to CopilotKit v2 managed-channels (channels 0.2)#10
jerelvelarde wants to merge 12 commits into
mainfrom
jerel/opentag-intelligence-v2

Conversation

@jerelvelarde

@jerelvelarde jerelvelarde commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Migrates OpenTag's Intelligence channel host (app/managed.ts, pnpm channel) from the deprecated @copilotkit/channels-intelligence gateway launcher to the v2 managed-channels runtime, so it connects to a current CopilotKit Intelligence project (opentag-dev / channel kite-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 to INTELLIGENCE_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 no adapters, activated when the node listener mounts.

⚠️ Overlaps #8 — read before merging

#8 (feat/intelligence-adapter) is a second, independent implementation of this same migration. Both bump @copilotkit/channels* to 0.2.1 and rewrite app/managed.ts. They conflict in 22 files with each other; #8 conflicts in ~15 with main.

#8 branches from df93bc0, which predates both merged PRs #6 (Intelligence gateway + Python agent/) and #7 (Railway IaC) — so it has no knowledge of agent/, .railway/, or the pnpm channel / pnpm agent scripts, and its package.json deletes the latter two.

#8 this PR
Shape createChannel({ adapters: [intelligenceAdapter()] }) no-adapter channel → CopilotRuntime({ channels })
Transport HTTP poll loop, channel.start() gateway WS, activated by the node listener
Env COPILOTKIT_INTELLIGENCE_URL, COPILOTKIT_API_KEY INTELLIGENCE_API_URL / _GATEWAY_WS_URL / _API_KEY
Platforms Slack + Teams Slack (provider defaults to slack)
Base df93bc0 (pre-#6, pre-#7) current main
Verified Slack + Teams end-to-end typecheck + 122 unit tests; no live run yet

Both APIs still exist in 0.2.1, so neither is wrong — but #8's own MANAGED.md documents 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/runtime floor ^1.62.3^1.63.2 (where the v2 channels API + createCopilotNodeListener ship).
  • Dropped the now-redundant direct @copilotkit/channels-intelligence dep (still present transitively).

Code

  • app/** migrated to the 0.2 API — mechanical rename: createBotcreateChannel, BotToolChannelTool, BotCommandChannelCommand, defineBotTooldefineChannelTool, defineBotCommanddefineChannelCommand, BotChannel, BotNodeChannelNode. Behavior unchanged.
  • app/index.ts (self-hosted) migrated to createChannel; per-platform gating / handlers / shutdown preserved.
  • app/managed.ts rewritten to the v2 managed-channels runtime: createChannel({ name: "kite-opentag" })new CopilotRuntime({ agents, intelligence, identifyUser, channels })createCopilotNodeListener; awaits listener.channels.ready() (fail-loud, exit non-zero on activation error), adds a bind-error handler, and normalizes blank env.

Config / docs

  • .env.example, .railway/railway.ts, README.md, setup.md updated to the v2 var set + runtime model.
  • .railway/railway.ts: INTELLIGENCE_API_URL/_GATEWAY_WS_URL are now preserve() (deployer-set, not clobbered by railway config apply); the channel service installs Chromium (playwright install --with-deps chromium + PLAYWRIGHT_BROWSERS_PATH=0 + runtime apt libs) so inline chart/diagram rendering works on deploy; .railway/** added to check-types.

Suggested review order

  1. app/managed.ts — the only genuinely new file. Everything else is either mechanical rename or config.
  2. app/managed.test.ts — covers the three defects below.
  3. .railway/railway.ts + .env.example — the deploy contract.
  4. Everything else is the rename set; git log -p per 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.ts fail-loud regression — success was logged even when gateway activation failed.
  • Empty-<Section> crashes (show_links single over-budget link; show_incident empty 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:

  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. "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.
  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 — clicks can't resolve and dead-letter. That silently stranded ConfirmWrite, the HITL gate on every write. Now registers it plus IncidentCard, with a source-scan test so a future interactive card can't be forgotten.
  3. Unauthenticated HTTP surface. createCopilotNodeListener publishes the whole v2 route set — agent/:id/run, threads/list, threads/messages, 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. Now gated via hooks.onRequest404 when CHANNEL_HTTP_TOKEN is unset (default), 401 on missing/wrong bearer when it is, constant-time compare. Verified against a real listener: 404/404/404 closed, 401/401/pass gated.

Testing

  • pnpm check-types: 0 errors (now includes .railway/).
  • pnpm test: 122 passing (12 files), incl. red-green tests for every behavioral fix.
  • Not done — live gateway verification (operator): set INTELLIGENCE_*, run pnpm agent + pnpm channel against opentag-dev, confirm kite-opentag flips Waiting for runtime → live, then upload a CSV with "chart this" (exercises fix 1) and click through a confirm_write gate (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-only PORT crashes (vs managed.ts, hardened here).
  • confirm-write-tool.tsx: awaitChoice rejection 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 .env treats them as configured.
  • .railway/railway.ts: builder pinning inconsistent across services.
  • vitest.config.ts: dead esbuild JSX block — confirmed live, the run prints Both esbuild and oxc options were set… esbuild options will be ignored.
  • Docs/naming: modal path bypasses confirm_write but docstring/setup imply a universal gate; NOTION_MCP_HOST undocumented; render-tools.test.ts misnamed; dead findByType helper; imprecise "≤5 inputs" title.

Worth porting from #8 afterwards

  • Child-process chart isolation (a Chromium crash can't take down the host) — but fix its two bugs first: process.exit(0) immediately after process.stdout.write(png) can truncate the PNG on a pipe, and the parent resolves that buffer with no length check; and the execFile timeout SIGTERMs the worker without a handler, so browser.close() never runs and Chromium orphans.
  • The hardened render_chart schema coercions (numeric labels, quoted numbers, tolerant options).
  • Canonical cross-platform reaction normalization (🔄 → emoji: "refresh" on every provider).
  • runner: new InMemoryAgentRunner({ onConcurrentRun: "supersede" }) in runtime.ts.
  • Teams, via 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

jerelvelarde and others added 12 commits July 24, 2026 08:59
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>
@jerelvelarde

Copy link
Copy Markdown
Collaborator Author

Review — v2 managed-channels migration

Scope: the PR diff only (38 files, +2659/−442). I verified the SDK contracts this PR depends on against the published @copilotkit/runtime@1.63.2 and @copilotkit/channels-core@0.2.1 type definitions rather than taking the PR description at face value.

Overview

Replaces the deprecated startChannelsOverRealtimeGateway launcher with the v2 managed-channels runtime (createChannelCopilotRuntime({ channels })createCopilotNodeListener), drops the org/project/channel ID triple in favour of API-creds + channel-name derivation, and carries a mechanical Bot*Channel* rename across app/**. Config, docs, and the Railway IaC follow.

The migration itself is sound. Confirmed against the SDK types that createChannel({ components }), hooks.onRequest (documented: "Throw a Response to short-circuit"), listener.channels.ready()/stop(), and the provider default of "slack" all exist and are used correctly. No stale createBot / BotTool / channels-intelligence references survive anywhere outside the lockfile and docs.

Blocking: the success log can still lie

app/managed.ts:291-297ready() does not mean the channel is live. From ChannelsControl:

ready(): "Resolve once every declared Channel has settled to a terminal, non-connecting state (online or setup_required)."
setup_required: "the Channel is declared but has no managed provider yet — a valid degraded state, not a failure."

Confirmed in channel-manager.mjs:215 — a SETUP_REQUIRED activation error sets the status and resolves settled. So a channel that exists in the dashboard but has no Slack connector bound resolves ready() cleanly, and this code prints (channel live) and stays up. That is the most likely first-run state for a deployer, and it is the same fail-loud regression class round 1 fixed. Gate the success line on status():

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

  • No ready() timeout (app/managed.ts:291). ready({ timeoutMs }) is supported and applied per channel. Without it, an activation that never settles leaves the process bound and silent, with no success or failure line — and the Railway channel service has no healthcheck, so nothing catches it.
  • Post-boot health is unobserved. Per ChannelManager, a session that exhausts its bounded reconnect window moves to error. Nothing polls status() after boot, so KiteBot can go offline with the process healthy and restartPolicyType: ON_FAILURE never fires. Same bug as above, one lifecycle stage later.
  • buildCommand drops --frozen-lockfile (.railway/railway.ts:90). Overriding Railpack's install step with bare pnpm install means a drifted lockfile can resolve different versions in production than the ones reviewed here.
  • The Chromium install is the riskiest unverified change. --with-deps needs root apt in the build layer and every runtime lib is already enumerated in RAILPACK_DEPLOY_APT_PACKAGES — plain npx playwright install chromium is strictly less fragile. It also assumes PLAYWRIGHT_BROWSERS_PATH=0 is visible at build time; if it is not, the browser lands in ~/.cache/ms-playwright and gets stripped. Given no live deploy in this PR, this path deserves one railway up before merge.

Nits

  • The component-scan guard in app/managed.test.ts only matches ^export function (\w+)\(. export const Foo = (...) => ... — the declaration style already used for fileIssueSubmit and the tool objects in those same files — slips through, so "a future interactive card can't be forgotten" is narrower than the comment claims.
  • Registering components buys durable action resolution only as far as the ActionStore is durable. With the default in-memory store a restart still loses actions; the MANAGED_COMPONENTS docstring (app/managed.ts:56-67) reads as if registration alone is sufficient.
  • app/managed.ts:277 labels every server error as "failed to bind". A signal arriving before listen resolves yields ERR_SERVER_NOT_RUNNING from server.close(), reported as a bind failure.
  • app/managed.ts:319server.close() is not awaited before process.exit. Harmless while the surface is closed by default; worth saying so in a comment.
  • app/managed.ts:252 — one static identifyUser identity means every CHANNEL_HTTP_TOKEN bearer shares a thread/memory namespace. Correct for the stated use; worth a line in the docstring.
  • The test uses new URL(".", import.meta.url).pathname; fileURLToPath is the correct call (percent-encoding).
  • CHANNEL_HTTP_TOKEN is documented in .env.example and the IaC but missing from both env tables in setup.md.
  • The repo has no CIgh pr checks reports nothing. "122 passing, 0 type errors" is a local-only claim on a 38-file migration. A minimal pnpm check-types && pnpm test workflow would be worth more than any remaining review round here.

What's good

The 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 sha256-then-timingSafeEqual compare is length-safe, and onRequest fires before routing on every request, so the closure is total. The promptFromMessage fix is correct and the replaced test is honest about what it was asserting. clampWithinBudget and toBudgetedMonospaceTable are clean, well-bounded, and report their drops instead of truncating silently. Comment density matches the surrounding code.

On the #8 overlap: that is a coordination call, not a code question — but this is the branch based on current main, and #8's Teams support and child-process chart isolation are both portable on top of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant