From 728361001a2bee799b1661925070b17112e7040c Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:28:36 -0700 Subject: [PATCH 01/12] docs(spec): KiteBot Railway one-click template (Phase 3) design Co-Authored-By: Claude Opus 4.8 --- ...6-07-16-kitebot-railway-oneclick-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md new file mode 100644 index 0000000..91cb853 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -0,0 +1,97 @@ +# KiteBot Railway One-Click Template — Phase 3 + +**Date:** 2026-07-16 +**Branch:** new branch off `main` (Phases 1 & 2 merged in PR #6) +**Status:** Design — awaiting review + +## Context / north star + +Phases 1 & 2 (merged) gave OpenTag a KiteBot channel host on the Intelligence Gateway +(`app/managed.ts`) and a Python LangGraph deepagents deep-research backend (`agent/`). +Phase 3 makes the whole stack **one-click deployable to Railway** — the "one-click install" +objective. Decisions locked with the user: feature the **Python deep-research agent** as the +backend, **include the Notion MCP sidecar**, and deliver it as an **`.railway/railway.ts` +Infrastructure-as-Code** file (all services in one TypeScript definition) plus README +deploy/button steps. + +## Grounding (Railway mechanics — verified via Railway docs) + +- **Config-as-Code** (`railway.json`/`.toml`) is **per-service** (build/start/health) and its + path is absolute from repo root. +- **Infrastructure-as-Code** (`.railway/railway.ts`, `railway/iac` SDK) defines the **whole + project — every service + its build/start/health + env + inter-service wiring — in one + file**, applied with `railway config apply` (CLI, needs `railway login` + `railway link`). +- **Reference variables** wire services: a service reads another's private domain via + `svc.env.RAILWAY_PRIVATE_DOMAIN` (compiles to `${{svc.RAILWAY_PRIVATE_DOMAIN}}`); Railway + resolves `${{other.PORT}}`-style refs at deploy. +- **Isolated monorepo:** each service sets a `rootDirectory`; the Python `agent/` and the TS + root are independent build roots. +- A literal **"Deploy on Railway" button** requires a *published* template (created in a + Railway account) — **needs auth**, so this phase authors the repo config + button steps but + cannot publish the template or deploy live from this environment. +- Reference: the deep-agents showcase ships `railway.toml` (nixpacks + uvicorn + `/health`). + +## Target architecture — 3 services in `.railway/railway.ts` + +All three build from this repo (`source: github("CopilotKit/OpenTag")`), differing by +`rootDirectory` + start command: + +1. **`agent`** — Python deep-research service. `rootDirectory: "agent"`, nixpacks, + `start: "uvicorn main:app --host 0.0.0.0 --port $PORT"`, `healthcheck: "/health"`. +2. **`notion-mcp`** — Notion MCP sidecar. root repo, `start: "pnpm notion-mcp"`, listens on + `$PORT` (the script honors `NOTION_MCP_PORT`/`PORT`). +3. **`channel`** — TS channel host. root repo, `start: "pnpm channel"`. Connects out to the + Intelligence gateway; no inbound public port needed. + +**Wiring (env in `railway.ts`, non-secret refs):** +- `channel.env.AGENT_URL = "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/"` +- `agent.env.NOTION_MCP_URL = "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp"` +- `agent.env.OPENAI_MODEL = "gpt-5.5"` (and any other non-secret defaults) + +**Secrets (deployer sets in the Railway UI; NOT stored in `railway.ts` — the IaC file +declares/preserves them, never contains values):** +- `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional), `NOTION_MCP_AUTH_TOKEN`, + optional `LINEAR_API_KEY`. +- `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (shared with `agent`). +- `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, + `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`, `INTELLIGENCE_CHANNEL_NAME`. + +## Artifacts + +- **`.railway/railway.ts`** — `defineRailway` returning `project("kitebot", { resources: [agent, notionMcp, channel] })` + with the three `service(...)` definitions, `rootDirectory`, build/start/health, the + reference-variable wiring above, non-secret env, and secrets via `preserve()` (so applying + doesn't clobber deployer-set values). +- **`agent/railway.toml`** (optional per-service belt) — nixpacks + uvicorn + `/health`, + patterned on the showcase, in case a deployer uses per-service config instead of IaC. +- **`README.md` "Deploy to Railway"** — two documented paths: (a) `railway login && railway + link && railway config apply` (IaC), and (b) publish a Railway template from the repo → get + a "Deploy on Railway" button (steps + the button markdown, noting it needs the deployer's + Railway account). Plus the full **secrets checklist** grouped by service. +- **`railway` devDependency** (or documented `npx`) so `.railway/railway.ts` type-resolves and + `railway config plan` works. + +## Verification (definition of done) + +1. `.railway/railway.ts` type-checks against the `railway/iac` types (`tsc`/`railway config + plan --json` dry parse; the latter needs auth — mark manual). +2. The README deploy steps + secrets checklist are complete and accurate to the 3 services. +3. Phase 1/2 gates unaffected (`pnpm check-types`, `pnpm test`, agent pytest still green). + +## Out of scope / manual (needs the deployer's Railway auth + secrets) + +- **Publishing the template + the live deploy** — `railway login`, `railway config apply`, and + setting the secret values in Railway are the deployer's steps (this environment is + unauthenticated and cannot enter secrets). This is the same live-creds gate as the Phase-2 + smoke test. + +## Open questions / to verify in the plan + +1. **Composite reference-variable env in the IaC DSL** — whether `AGENT_URL`/`NOTION_MCP_URL` + are best expressed as literal `"...${{svc.VAR}}..."` strings (Railway resolves at deploy) + or via the DSL's typed `svc.env.*` refs (which may not compose into a URL string). Resolve + against the `railway/iac` reference during implementation; prefer the literal `${{...}}` + form if typed refs don't concatenate. +2. **`agent` builder** — nixpacks auto-detects the `uv` project, or an explicit Dockerfile is + needed (the showcase used nixpacks + a `startCommand`). Verify; fall back to a Dockerfile if + nixpacks can't resolve the `uv`/Python 3.12 toolchain. From f1b79549b7e00a15b3a71d42920232fa65cad820 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:47:03 -0700 Subject: [PATCH 02/12] docs(plan): KiteBot Railway one-click template (Phase 3) implementation plan Co-Authored-By: Claude Opus 4.8 --- .../2026-07-16-kitebot-railway-oneclick.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md new file mode 100644 index 0000000..940304f --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -0,0 +1,215 @@ +# KiteBot Railway One-Click Template (Phase 3) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the KiteBot stack one-click deployable to Railway via a `.railway/railway.ts` Infrastructure-as-Code file defining 3 wired services (Python deep-research `agent`, `notion-mcp` sidecar, TS `channel` host), plus per-service build config and README deploy docs. + +**Architecture:** Railway IaC (`railway/iac` SDK) declares all 3 services from this one repo (isolated monorepo via per-service `rootDirectory`), their build/start/health, non-secret env + inter-service reference-variable wiring, and secrets via `preserve()` (deployer sets values in Railway). Applied with `railway config apply`. Secrets + the "Deploy on Railway" button + the live deploy are the deployer's steps (need `railway login`). + +**Tech Stack:** Railway IaC (`railway` npm SDK v3.5.7, `railway/iac`), TypeScript, nixpacks (agent + node services), the existing `pnpm channel` / `pnpm notion-mcp` scripts and the `agent/` uv project. + +## Global Constraints +- Secrets are NEVER written into `.railway/railway.ts` or any committed file — declared via `preserve()` and set by the deployer in Railway. Only non-secret wiring/defaults are literal. +- Reference-variable wiring uses literal Railway `${{service.VAR}}` strings in env values (resolved by Railway at deploy) — service names: `agent`, `notion-mcp`, `channel`. +- Model default stays `gpt-5.5`; channel name default `kitebot` (consistent with Phases 1–2). +- Do not disturb the Phase-1/2 gates: `pnpm check-types`, `pnpm test`, and `agent` pytest must stay green. `.railway/` is outside the root tsconfig `include`, so it won't affect `pnpm check-types`. + +--- + +## Task 1: Railway IaC definition + SDK dependency + +**Files:** +- Create: `.railway/railway.ts` +- Modify: `package.json` (add `railway` devDependency) + +**Interfaces:** +- Produces: a `.railway/railway.ts` that `railway config plan/apply` consumes; 3 services named `agent`, `notion-mcp`, `channel`. + +- [ ] **Step 1: Add the `railway` SDK devDependency** + +Run: `pnpm add -D railway@^3.5.7` +Expected: adds `railway` to `devDependencies`; `pnpm-lock.yaml` updates; resolves cleanly. + +- [ ] **Step 2: Create `.railway/railway.ts`** + +```ts +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// KiteBot on CopilotKit Intelligence — one-click Railway topology. +// Three services build from this repo; the Python agent uses rootDirectory "agent". +// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at +// deploy over private networking. SECRETS are declared with preserve() so applying +// never clobbers deployer-set values — set their actual values in the Railway UI +// (see README "Deploy to Railway"). +const REPO = "CopilotKit/OpenTag"; + +export default defineRailway(() => { + // Notion MCP sidecar — streamable-HTTP MCP server on $PORT. + const notionMcp = service("notion-mcp", { + source: github(REPO), + start: "pnpm notion-mcp", + env: { + // secrets (set in Railway UI): + NOTION_TOKEN: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + }, + }); + + // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). + const agent = service("agent", { + source: github(REPO, { rootDirectory: "agent" }), + start: "uvicorn main:app --host 0.0.0.0 --port $PORT", + healthcheck: "/health", + env: { + OPENAI_MODEL: "gpt-5.5", + // internal research source (optional Notion), wired to the sidecar: + NOTION_MCP_URL: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp", + // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: + OPENAI_API_KEY: preserve(), + TAVILY_API_KEY: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + LINEAR_API_KEY: preserve(), + }, + }); + + // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. + const channel = service("channel", { + source: github(REPO), + start: "pnpm channel", + env: { + // brain: points at the agent service over private networking: + AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + INTELLIGENCE_CHANNEL_NAME: "kitebot", + // secrets (set in Railway UI): + INTELLIGENCE_GATEWAY_WS_URL: preserve(), + INTELLIGENCE_API_KEY: preserve(), + INTELLIGENCE_ORG_ID: preserve(), + INTELLIGENCE_PROJECT_ID: preserve(), + INTELLIGENCE_CHANNEL_ID: preserve(), + }, + }); + + return project("kitebot", { resources: [notionMcp, agent, channel] }); +}); +``` + +- [ ] **Step 3: Type-check the IaC file against the real `railway/iac` types** + +Run: `pnpm exec tsc --noEmit --strict --module nodenext --moduleResolution nodenext --skipLibCheck .railway/railway.ts` +Expected: no errors (the `railway/iac` exports `defineRailway`, `github`, `preserve`, `project`, `service` per the SDK's `dist/iac/index.d.ts`). If `preserve` or `github` is not exported under this SDK version, check `node_modules/railway/dist/iac/index.d.ts` and adjust the import/usage to the actual export names; if composite `${{...}}` literal env strings are rejected by the env value type, keep them as plain strings (they are strings) — the type is `string`, so literals pass. + +- [ ] **Step 4: Confirm the Phase-1/2 gate is unaffected** + +Run: `pnpm check-types` +Expected: PASS (`.railway/` is not in the root tsconfig `include`, so this is unchanged from main). + +- [ ] **Step 5: Commit** + +```bash +git add .railway/railway.ts package.json pnpm-lock.yaml +git commit -m "feat(deploy): Railway IaC — agent + notion-mcp + channel services, wired" +``` + +--- + +## Task 2: Per-service build config for the agent + +**Files:** +- Create: `agent/railway.toml` + +**Interfaces:** +- Consumes: nothing. Produces: a per-service config Railway reads if a deployer configures the `agent` service with config path `/agent/railway.toml` instead of (or alongside) IaC. + +- [ ] **Step 1: Create `agent/railway.toml`** (patterned on the deep-agents showcase; nixpacks builds the `uv` project) + +```toml +[build] +builder = "nixpacks" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8123}" +healthcheckPath = "/health" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 +``` + +- [ ] **Step 2: Sanity-check TOML validity** + +Run: `pnpm exec node -e "const fs=require('fs');const s=fs.readFileSync('agent/railway.toml','utf8');if(!/builder\s*=\s*\"nixpacks\"/.test(s)||!/healthcheckPath\s*=\s*\"\/health\"/.test(s))throw new Error('bad toml');console.log('railway.toml ok')"` +Expected: `railway.toml ok`. + +- [ ] **Step 3: Commit** + +```bash +git add agent/railway.toml +git commit -m "feat(deploy): agent/railway.toml (nixpacks + uvicorn + /health)" +``` + +--- + +## Task 3: README "Deploy to Railway" section + secrets checklist + +**Files:** +- Modify: `README.md` + +**Interfaces:** +- Consumes: the service names + secrets from Task 1. Produces: deployer-facing docs. + +- [ ] **Step 1: Add a "Deploy to Railway (one-click)" section to `README.md`** + +Insert a section covering, accurately to the 3 services: +- **What deploys:** 3 services from this repo — `agent` (Python deep-research, `rootDirectory: agent`), `notion-mcp` (`pnpm notion-mcp`), `channel` (`pnpm channel`); the `channel` reaches `agent` and `agent` reaches `notion-mcp` over Railway private networking (auto-wired by the IaC). +- **Deploy via IaC (recommended):** + ```bash + npm i -g @railway/cli # or: brew install railway + railway login + railway link # create/select a Railway project + railway config apply # provisions agent + notion-mcp + channel from .railway/railway.ts + ``` +- **Set the secrets** (Railway → each service → Variables) — grouped checklist: + - `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional — enables web research), `NOTION_MCP_AUTH_TOKEN` (must match `notion-mcp`), `LINEAR_API_KEY` (optional). + - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (same value as `agent`). + - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project/channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. +- **"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo as a Railway template (Railway dashboard → Templates → New, or `railway` template flow) — that requires your Railway account — then add the generated button: + `[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/)` +- **Note:** applying the IaC creates the services + wiring; KiteBot goes live only once the secrets are set and the channel connects (dashboard flips *Waiting for runtime → live*). + +- [ ] **Step 2: Verify the section references only real service names + env vars** + +Run: `grep -nE "notion-mcp|INTELLIGENCE_(GATEWAY_WS_URL|API_KEY|ORG_ID|PROJECT_ID|CHANNEL_ID|CHANNEL_NAME)|OPENAI_API_KEY|NOTION_MCP_AUTH_TOKEN|railway config apply" README.md` +Expected: all present; cross-check names byte-match `.railway/railway.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs(deploy): README Deploy-to-Railway section + secrets checklist" +``` + +--- + +## Task 4: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Phase-1/2 gates unaffected** + +Run: `pnpm check-types && pnpm test` and `cd agent && OPENAI_API_KEY=sk-dummy uv run pytest tests/ -q` +Expected: TS check-types PASS + all TS tests pass; agent tests pass. (This phase adds only deploy config + docs + a devDep; no source behavior changes.) + +- [ ] **Step 2: IaC file resolves against the SDK** + +Run: `pnpm exec tsc --noEmit --strict --module nodenext --moduleResolution nodenext --skipLibCheck .railway/railway.ts` +Expected: no errors. + +- [ ] **Step 3 (MANUAL — needs the deployer's Railway auth):** `railway login && railway link && railway config plan` validates the topology against a real Railway project, and `railway config apply` + setting the secrets deploys it live. Cannot run in this environment (unauthenticated; secrets are the deployer's). Flag for the maintainer. + +--- + +## Notes for the executor +- Secrets never get literal values in any committed file — `preserve()` only. If a reviewer sees a real key, that's a defect. +- If `railway/iac` under v3.5.7 differs from the documented API (export names, `github` options, env value types), adapt to the installed `node_modules/railway/dist/iac/index.d.ts` and note the deviation — the DSL shape here matches the Railway IaC reference but the SDK is the source of truth. +- The literal `${{svc.VAR}}` env strings are intentional (Railway reference variables); do not "resolve" them to typed refs unless the SDK requires it. +- Live deploy + the published "Deploy on Railway" button are out of scope (need the deployer's Railway login + secrets) — same live-creds gate as the Phase-2 smoke test. From 0bd2fd5b247e627c7282fc6ae96b7c3b700a2df7 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:22:36 -0700 Subject: [PATCH 03/12] =?UTF-8?q?feat(deploy):=20Railway=20IaC=20=E2=80=94?= =?UTF-8?q?=20agent=20+=20notion-mcp=20+=20channel=20services,=20wired?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .railway/railway.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 ++-- pnpm-lock.yaml | 14 +++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .railway/railway.ts diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..981fd34 --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,59 @@ +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// KiteBot on CopilotKit Intelligence — one-click Railway topology. +// Three services build from this repo; the Python agent uses rootDirectory "agent". +// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at +// deploy over private networking. SECRETS are declared with preserve() so applying +// never clobbers deployer-set values — set their actual values in the Railway UI +// (see README "Deploy to Railway"). +const REPO = "CopilotKit/OpenTag"; + +export default defineRailway(() => { + // Notion MCP sidecar — streamable-HTTP MCP server on $PORT. + const notionMcp = service("notion-mcp", { + source: github(REPO), + start: "pnpm notion-mcp", + env: { + // secrets (set in Railway UI): + NOTION_TOKEN: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + }, + }); + + // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). + const agent = service("agent", { + source: github(REPO, { rootDirectory: "agent" }), + start: "uvicorn main:app --host 0.0.0.0 --port $PORT", + healthcheck: "/health", + env: { + OPENAI_MODEL: "gpt-5.5", + // internal research source (optional Notion), wired to the sidecar: + NOTION_MCP_URL: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp", + // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: + OPENAI_API_KEY: preserve(), + TAVILY_API_KEY: preserve(), + NOTION_MCP_AUTH_TOKEN: preserve(), + LINEAR_API_KEY: preserve(), + }, + }); + + // KiteBot channel host — runs the bot over the Intelligence Realtime Gateway. + const channel = service("channel", { + source: github(REPO), + start: "pnpm channel", + env: { + // brain: points at the agent service over private networking: + AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + INTELLIGENCE_CHANNEL_NAME: "kitebot", + // secrets (set in Railway UI): + INTELLIGENCE_GATEWAY_WS_URL: preserve(), + INTELLIGENCE_API_KEY: preserve(), + INTELLIGENCE_ORG_ID: preserve(), + INTELLIGENCE_PROJECT_ID: preserve(), + INTELLIGENCE_CHANNEL_ID: preserve(), + }, + }); + + return project("kitebot", { resources: [notionMcp, agent, channel] }); +}); diff --git a/package.json b/package.json index 44c3431..a4abec4 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,11 @@ "@copilotkit/channels-ui": "^0.1.1", "@copilotkit/channels-whatsapp": "^0.0.2", "@copilotkit/runtime": "^1.62.3", + "@slack/bolt": "^4.2.0", + "@slack/types": "^2.21.1", "@tanstack/ai": "^0.32.0", "@tanstack/ai-mcp": "^0.1.3", "@tanstack/ai-openai": "^0.15.2", - "@slack/bolt": "^4.2.0", - "@slack/types": "^2.21.1", "playwright": "^1.49.0", "zod": "^3.25.76" }, @@ -39,6 +39,7 @@ "@notionhq/notion-mcp-server": "^2.2.1", "@types/node": "^22.10.0", "dotenv": "^16.4.5", + "railway": "^3.5.7", "tsx": "^4.19.2", "typescript": "^5.6.3", "vitest": "^4.1.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e7f141..8527ce3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.6.1 + railway: + specifier: ^3.5.7 + version: 3.5.7 tsx: specifier: ^4.19.2 version: 4.23.1 @@ -1984,6 +1987,11 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + railway@3.5.7: + resolution: {integrity: sha512-2DdaAw2eSQuCtSn+/VuPNOdnbynxTVmRIQlefhc0maULZTr16Uy/5/FjumnI1lONZgflKG/WMGm9QmUdnApZFw==} + engines: {node: '>=22'} + hasBin: true + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4538,6 +4546,12 @@ snapshots: quick-format-unescaped@4.0.4: {} + railway@3.5.7: + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + graphql: 16.14.2 + tsx: 4.23.1 + range-parser@1.2.1: {} range-parser@1.3.0: {} From bd9b95f03c0c280912f9e1756b7dfcce366b607a Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:22:36 -0700 Subject: [PATCH 04/12] feat(deploy): agent/railway.toml (nixpacks + uvicorn + /health) Co-Authored-By: Claude Opus 4.8 --- agent/railway.toml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 agent/railway.toml diff --git a/agent/railway.toml b/agent/railway.toml new file mode 100644 index 0000000..3142560 --- /dev/null +++ b/agent/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "nixpacks" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8123}" +healthcheckPath = "/health" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 From 709a241a91ffa4f4452d93a78c390be9555745c0 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:22:36 -0700 Subject: [PATCH 05/12] docs(deploy): README Deploy-to-Railway section + secrets checklist Co-Authored-By: Claude Opus 4.8 --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index bfba6d3..484f5da 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,52 @@ With `agent/` in the mix, the local setup is now three pieces: the deep-research `:8200`) as before. `agent` and `runtime` are alternative brains for the same bot; run one or the other depending on what `AGENT_URL` targets. +## Deploy to Railway (one-click) + +The whole KiteBot stack deploys to [Railway](https://railway.com) as **three services**, all +built from this one repo and wired together automatically over Railway's private networking: + +| Service | What it is | Build | +| --- | --- | --- | +| `agent` | the Python deep-research backend (`agent/`) | nixpacks, `uvicorn`, `/health` (root dir `agent/`) | +| `notion-mcp` | the Notion MCP sidecar (`pnpm notion-mcp`) | Node | +| `channel` | the KiteBot channel host (`pnpm channel`) | Node | + +`channel` reaches `agent` via `AGENT_URL`, and `agent` reaches `notion-mcp` via `NOTION_MCP_URL` +— both wired with Railway reference variables in [`.railway/railway.ts`](./.railway/railway.ts), +so you don't set them by hand. + +**Deploy via Infrastructure-as-Code (recommended):** + +```bash +npm i -g @railway/cli # or: brew install railway +railway login +railway link # create or select a Railway project +railway config apply # provisions agent + notion-mcp + channel from .railway/railway.ts +``` + +**Set the secrets** (Railway → each service → *Variables*). The IaC declares them with +`preserve()` and never stores values — you fill them in: + +- **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research); + `NOTION_MCP_AUTH_TOKEN` (must match `notion-mcp`); `LINEAR_API_KEY` (optional). +- **`notion-mcp`** — `NOTION_TOKEN`; `NOTION_MCP_AUTH_TOKEN` (same value as `agent`). +- **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, + `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence + project + channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. + +Applying the config creates the services and their wiring; **KiteBot goes live only once the +secrets are set and the `channel` service connects** — that's when your Intelligence dashboard +flips *Waiting for runtime → live*. + +**"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo +as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, +then drop the generated button in: + +```md +[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/) +``` + ## Don't want to host it yourself? Self-hosting means you run and scale the runtime, persistence, and inspection tooling yourself. From b274500a57433058d1033b291cbac7c19c8e0e9d Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:35:39 -0700 Subject: [PATCH 06/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=201=20?= =?UTF-8?q?=E2=80=94=20port=20wiring,=20single-source=20config,=20IPv6,=20?= =?UTF-8?q?runtime=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notion-mcp: pin NOTION_MCP_PORT=3001; agent dials ${{notion-mcp.NOTION_MCP_PORT}} (the sidecar binds NOTION_MCP_PORT, never Railway's $PORT — previous ${{notion-mcp.PORT}} pointed at a port nothing listened on). - agent: fold agent/railway.toml into the IaC build/deploy config and delete the toml — Railway forbids a service being managed by both IaC and config-as-code. Carries builder=NIXPACKS, healthcheckTimeout=300, restartPolicy ON_FAILURE. - agent: pin PORT=8123 so uvicorn's bind and ${{agent.PORT}} always agree. - agent: bind uvicorn --host :: for Railway dual-stack/IPv6 private networking. - deps: move tsx + dotenv to dependencies (runtime-required by the channel and notion-mcp services; would be pruned from a production-only install). - docs: correct spec's notion-mcp port claim and INTELLIGENCE_CHANNEL_NAME classification; note OPENAI_MODEL default in README. Co-Authored-By: Claude Opus 4.8 --- .railway/railway.ts | 38 ++++++++++++++++--- README.md | 4 ++ agent/railway.toml | 9 ----- ...6-07-16-kitebot-railway-oneclick-design.md | 21 ++++++---- package.json | 4 +- pnpm-lock.yaml | 12 +++--- 6 files changed, 58 insertions(+), 30 deletions(-) delete mode 100644 agent/railway.toml diff --git a/.railway/railway.ts b/.railway/railway.ts index 981fd34..f377b60 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -6,14 +6,23 @@ import { defineRailway, github, preserve, project, service } from "railway/iac"; // deploy over private networking. SECRETS are declared with preserve() so applying // never clobbers deployer-set values — set their actual values in the Railway UI // (see README "Deploy to Railway"). +// +// Ports are pinned as explicit service variables (NOT left to Railway's +// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} its +// peers dial always agree — see the notion-mcp and agent notes below. const REPO = "CopilotKit/OpenTag"; export default defineRailway(() => { - // Notion MCP sidecar — streamable-HTTP MCP server on $PORT. + // Notion MCP sidecar — streamable-HTTP MCP server. Its launcher + // (scripts/start-notion-mcp.ts) binds NOTION_MCP_PORT (default 3001) and does + // NOT read Railway's injected $PORT, so we pin NOTION_MCP_PORT here and have + // the agent dial that same variable — otherwise ${{notion-mcp.PORT}} would + // point at a port nothing listens on. const notionMcp = service("notion-mcp", { source: github(REPO), start: "pnpm notion-mcp", env: { + NOTION_MCP_PORT: "3001", // secrets (set in Railway UI): NOTION_TOKEN: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), @@ -21,15 +30,31 @@ export default defineRailway(() => { }); // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). + // This service owns its build + deploy config here (single source of truth): + // there is deliberately NO agent/railway.toml, because Railway forbids a + // service being managed by both IaC and config-as-code at once. const agent = service("agent", { source: github(REPO, { rootDirectory: "agent" }), - start: "uvicorn main:app --host 0.0.0.0 --port $PORT", - healthcheck: "/health", + build: { builder: "NIXPACKS" }, + deploy: { + // agent/main.py reads PORT first (default 8123); bind :: so the service is + // reachable over Railway private networking on BOTH IPv4 and IPv6 (legacy + // environments are IPv6-only). The ${PORT:-8123} fallback keeps the bind + // valid even if PORT is ever unset; PORT is pinned in env below. + startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + healthcheckPath: "/health", + healthcheckTimeout: 300, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, env: { + // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. + PORT: "8123", OPENAI_MODEL: "gpt-5.5", - // internal research source (optional Notion), wired to the sidecar: + // internal research source (optional Notion), wired to the sidecar on its + // pinned NOTION_MCP_PORT over private networking: NOTION_MCP_URL: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp", + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), @@ -43,7 +68,8 @@ export default defineRailway(() => { source: github(REPO), start: "pnpm channel", env: { - // brain: points at the agent service over private networking: + // brain: points at the agent service over private networking. The agent + // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", INTELLIGENCE_CHANNEL_NAME: "kitebot", // secrets (set in Railway UI): diff --git a/README.md b/README.md index 484f5da..6918d96 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,10 @@ railway config apply # provisions agent + notion-mcp + channel from .rai `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project + channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. +Non-secret config (the agent's `OPENAI_MODEL`, defaulting to `gpt-5.5`, and the inter-service +URLs/ports) is set for you in [`.railway/railway.ts`](./.railway/railway.ts) — override +`OPENAI_MODEL` in the `agent` service's *Variables* if you want a different model. + Applying the config creates the services and their wiring; **KiteBot goes live only once the secrets are set and the `channel` service connects** — that's when your Intelligence dashboard flips *Waiting for runtime → live*. diff --git a/agent/railway.toml b/agent/railway.toml deleted file mode 100644 index 3142560..0000000 --- a/agent/railway.toml +++ /dev/null @@ -1,9 +0,0 @@ -[build] -builder = "nixpacks" - -[deploy] -startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8123}" -healthcheckPath = "/health" -healthcheckTimeout = 300 -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 5 diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index 91cb853..01abc55 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -36,17 +36,23 @@ deploy/button steps. All three build from this repo (`source: github("CopilotKit/OpenTag")`), differing by `rootDirectory` + start command: -1. **`agent`** — Python deep-research service. `rootDirectory: "agent"`, nixpacks, - `start: "uvicorn main:app --host 0.0.0.0 --port $PORT"`, `healthcheck: "/health"`. -2. **`notion-mcp`** — Notion MCP sidecar. root repo, `start: "pnpm notion-mcp"`, listens on - `$PORT` (the script honors `NOTION_MCP_PORT`/`PORT`). +1. **`agent`** — Python deep-research service. `rootDirectory: "agent"`, nixpacks + (`build.builder: "NIXPACKS"`), `startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}"` + (bind `::` for Railway's dual-stack/IPv6 private network), `healthcheckPath: "/health"`, + `healthcheckTimeout: 300`, `restartPolicyType: "ON_FAILURE"`. All build+deploy config lives + in `railway.ts`; there is **no** `agent/railway.toml` (Railway forbids a service being managed + by both IaC and config-as-code). +2. **`notion-mcp`** — Notion MCP sidecar. root repo, `start: "pnpm notion-mcp"`. The launcher + (`scripts/start-notion-mcp.ts`) binds `NOTION_MCP_PORT` (default `3001`) and does **not** read + Railway's `$PORT`, so the IaC pins `NOTION_MCP_PORT: "3001"` and the agent dials that same var. 3. **`channel`** — TS channel host. root repo, `start: "pnpm channel"`. Connects out to the Intelligence gateway; no inbound public port needed. **Wiring (env in `railway.ts`, non-secret refs):** -- `channel.env.AGENT_URL = "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/"` -- `agent.env.NOTION_MCP_URL = "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp"` +- `channel.env.AGENT_URL = "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/"` (agent pins `PORT: "8123"`) +- `agent.env.NOTION_MCP_URL = "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp"` - `agent.env.OPENAI_MODEL = "gpt-5.5"` (and any other non-secret defaults) +- `channel.env.INTELLIGENCE_CHANNEL_NAME = "kitebot"` (non-secret default) **Secrets (deployer sets in the Railway UI; NOT stored in `railway.ts` — the IaC file declares/preserves them, never contains values):** @@ -54,7 +60,8 @@ declares/preserves them, never contains values):** optional `LINEAR_API_KEY`. - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (shared with `agent`). - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, - `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`, `INTELLIGENCE_CHANNEL_NAME`. + `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`. (`INTELLIGENCE_CHANNEL_NAME` is **not** a + secret — it's a non-secret default of `"kitebot"` set in `railway.ts`.) ## Artifacts diff --git a/package.json b/package.json index a4abec4..36c213a 100644 --- a/package.json +++ b/package.json @@ -32,15 +32,15 @@ "@tanstack/ai": "^0.32.0", "@tanstack/ai-mcp": "^0.1.3", "@tanstack/ai-openai": "^0.15.2", + "dotenv": "^16.4.5", "playwright": "^1.49.0", + "tsx": "^4.19.2", "zod": "^3.25.76" }, "devDependencies": { "@notionhq/notion-mcp-server": "^2.2.1", "@types/node": "^22.10.0", - "dotenv": "^16.4.5", "railway": "^3.5.7", - "tsx": "^4.19.2", "typescript": "^5.6.3", "vitest": "^4.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8527ce3..6ffd13b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,15 @@ importers: '@tanstack/ai-openai': specifier: ^0.15.2 version: 0.15.10(@tanstack/ai@0.32.0(@opentelemetry/api@1.9.1))(ws@8.21.1)(zod@3.25.76) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 playwright: specifier: ^1.49.0 version: 1.61.1 + tsx: + specifier: ^4.19.2 + version: 4.23.1 zod: specifier: ^3.25.76 version: 3.25.76 @@ -63,15 +69,9 @@ importers: '@types/node': specifier: ^22.10.0 version: 22.20.1 - dotenv: - specifier: ^16.4.5 - version: 16.6.1 railway: specifier: ^3.5.7 version: 3.5.7 - tsx: - specifier: ^4.19.2 - version: 4.23.1 typescript: specifier: ^5.6.3 version: 5.9.3 From 11405956c27b3ddd4447b0b0b2a0c69fc71a3697 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:10 -0700 Subject: [PATCH 07/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=202=20?= =?UTF-8?q?=E2=80=94=20sidecar=20IPv6=20bind,=20model=20override,=20deps,?= =?UTF-8?q?=20doc=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar: scripts/start-notion-mcp.ts gains NOTION_MCP_HOST (validated, default 127.0.0.1); IaC sets it to :: so the notion-mcp server (upstream default 127.0.0.1) is reachable over Railway's IPv6 private network. Without this the agent's cross-container Notion connection was refused even with the port fixed. - OPENAI_MODEL: stop managing it in the IaC (the agent defaults to gpt-5.5) so a UI override survives 'railway config apply' — was a plain value that a re-apply would silently clobber, contradicting the README's 'override in Variables' note. - NOTION_MCP_AUTH_TOKEN: agent references ${{notion-mcp.NOTION_MCP_AUTH_TOKEN}} (single source of truth) instead of a second hand-matched secret. - notion-mcp: add ON_FAILURE restart policy; document that it requires its two tokens and how to omit the service if you don't want Notion. - deps: move @notionhq/notion-mcp-server to dependencies (runtime-required by the deployed notion-mcp service; npx -y would otherwise fetch latest at cold start). - docs: sync spec + plan to the shipped implementation (no agent/railway.toml, IPv6 bind, port pinning, model unmanaged); fix README PORT/SERVER_PORT order, Tavily-vs-Responses web-search note, and channels-intelligence optionality. Co-Authored-By: Claude Opus 4.8 --- .railway/railway.ts | 50 ++++++-- README.md | 23 ++-- .../2026-07-16-kitebot-railway-oneclick.md | 113 +++++++++++------- ...6-07-16-kitebot-railway-oneclick-design.md | 13 +- package.json | 2 +- pnpm-lock.yaml | 6 +- scripts/start-notion-mcp.ts | 18 +++ 7 files changed, 152 insertions(+), 73 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index f377b60..20cfe30 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -7,22 +7,40 @@ import { defineRailway, github, preserve, project, service } from "railway/iac"; // never clobbers deployer-set values — set their actual values in the Railway UI // (see README "Deploy to Railway"). // -// Ports are pinned as explicit service variables (NOT left to Railway's -// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} its -// peers dial always agree — see the notion-mcp and agent notes below. +// Two topology invariants this file encodes: +// 1. Ports are pinned as explicit service variables (NOT left to Railway's +// auto-injected $PORT) so each service's listen port and the ${{svc.PORT}} +// its peers dial always agree. +// 2. Services reached over Railway private networking must bind :: (all +// interfaces) — private DNS (RAILWAY_PRIVATE_DOMAIN) resolves to IPv6, and +// legacy environments are IPv6-only. A service bound to 127.0.0.1/0.0.0.0 +// is unreachable by its peers. const REPO = "CopilotKit/OpenTag"; export default defineRailway(() => { // Notion MCP sidecar — streamable-HTTP MCP server. Its launcher // (scripts/start-notion-mcp.ts) binds NOTION_MCP_PORT (default 3001) and does // NOT read Railway's injected $PORT, so we pin NOTION_MCP_PORT here and have - // the agent dial that same variable — otherwise ${{notion-mcp.PORT}} would - // point at a port nothing listens on. + // the agent dial that same variable. We also set NOTION_MCP_HOST=:: so the + // sidecar binds all interfaces (its upstream default is 127.0.0.1, which is + // unreachable across containers on the private network). + // + // Notion is an OPTIONAL research source: the agent runs fine (chat + UI + web + // research) without it and drops the Notion tools if this sidecar is + // unreachable. But if you deploy this service it REQUIRES NOTION_TOKEN and + // NOTION_MCP_AUTH_TOKEN — its launcher exits non-zero without them. To skip + // Notion entirely, remove this service from `resources` below and delete the + // agent's NOTION_MCP_URL / NOTION_MCP_AUTH_TOKEN wiring. const notionMcp = service("notion-mcp", { source: github(REPO), start: "pnpm notion-mcp", + deploy: { + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, env: { NOTION_MCP_PORT: "3001", + NOTION_MCP_HOST: "::", // secrets (set in Railway UI): NOTION_TOKEN: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), @@ -37,10 +55,12 @@ export default defineRailway(() => { source: github(REPO, { rootDirectory: "agent" }), build: { builder: "NIXPACKS" }, deploy: { - // agent/main.py reads PORT first (default 8123); bind :: so the service is - // reachable over Railway private networking on BOTH IPv4 and IPv6 (legacy - // environments are IPv6-only). The ${PORT:-8123} fallback keeps the bind - // valid even if PORT is ever unset; PORT is pinned in env below. + // Bind :: so the agent is reachable over Railway private networking (see + // invariant 2 above). The Railway startCommand runs `uvicorn main:app` + // directly, so agent/main.py's own __main__ port logic does NOT run here — + // the port comes solely from this --port arg. The ${PORT:-8123} fallback + // keeps the bind valid even if PORT were unset; PORT is pinned in env below + // so this and ${{agent.PORT}} (dialed by channel) always agree. startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", healthcheckPath: "/health", healthcheckTimeout: 300, @@ -50,15 +70,21 @@ export default defineRailway(() => { env: { // Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree. PORT: "8123", - OPENAI_MODEL: "gpt-5.5", // internal research source (optional Notion), wired to the sidecar on its - // pinned NOTION_MCP_PORT over private networking: + // pinned NOTION_MCP_PORT over private networking. The shared bearer is + // referenced from notion-mcp so it has a single source of truth — set + // NOTION_MCP_AUTH_TOKEN once, on the notion-mcp service. NOTION_MCP_URL: "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", + // OPENAI_MODEL is intentionally NOT set here: the agent defaults to + // gpt-5.5 (agent/tools.py), and leaving it unmanaged means a deployer can + // override it in the Railway UI without a later `config apply` clobbering + // the change. Set OPENAI_MODEL in the agent service's Variables to change it. + // // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), - NOTION_MCP_AUTH_TOKEN: preserve(), LINEAR_API_KEY: preserve(), }, }); diff --git a/README.md b/README.md index 6918d96..f20309d 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ OpenTag is a thin layer on top of a handful of CopilotKit packages. The `pnpm in | Package | When you need it | | --- | --- | | [`@copilotkit/channels-discord`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-discord) · [`-telegram`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-telegram) · [`-whatsapp`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-whatsapp) | Running on a platform other than Slack — one adapter per platform. | -| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. | +| [`@copilotkit/channels-intelligence`](https://github.com/CopilotKit/CopilotKit/tree/main/packages/channels-intelligence) | Runs the bot over the CopilotKit Intelligence Realtime Gateway instead of holding platform tokens — see `app/managed.ts`. **Required for the recommended `pnpm channel` (Intelligence Gateway) mode**; omit it only if you run self-hosted mode exclusively. | **1. Create a Slack app.** At [api.slack.com/apps](https://api.slack.com/apps?new_app=1) → *From a manifest* → paste [`slack-app-manifest.yaml`](./slack-app-manifest.yaml). Install it, @@ -70,7 +70,8 @@ CopilotKit Intelligence project for Intelligence mode. Step-by-step in **2. Set your secrets** in `.env` (`cp .env.example .env`): ```bash -OPENAI_API_KEY=sk-... # the agent runs on OpenAI's Responses API (required for web search) +OPENAI_API_KEY=sk-... # the TS runtime (runtime.ts) uses OpenAI's Responses API for web search; + # the Python agent/ uses Tavily instead (see "Deep research" below) # Self-hosted mode: SLACK_BOT_TOKEN=xoxb-... @@ -137,7 +138,7 @@ To run it: ```bash cd agent && uv sync # requires uv: https://docs.astral.sh/uv/ pnpm agent # cd agent && uv run python main.py — serves over AG-UI on :8123 - # (port from SERVER_PORT/PORT env, default 8123) + # (port from PORT/SERVER_PORT env, default 8123) ``` Then point the bot at it instead of `runtime.ts` by setting in `.env`: @@ -180,15 +181,21 @@ railway config apply # provisions agent + notion-mcp + channel from .rai `preserve()` and never stores values — you fill them in: - **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research); - `NOTION_MCP_AUTH_TOKEN` (must match `notion-mcp`); `LINEAR_API_KEY` (optional). -- **`notion-mcp`** — `NOTION_TOKEN`; `NOTION_MCP_AUTH_TOKEN` (same value as `agent`). + `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is **not** set here — the agent references + it from `notion-mcp`, so you set it once, below.) +- **`notion-mcp`** — `NOTION_TOKEN` and `NOTION_MCP_AUTH_TOKEN` (any strong string; the agent + reads the same value via a reference variable). **Both are required** for this service to + start — its launcher exits if either is missing. Don't want Notion? Remove the `notion-mcp` + service from `resources` in [`.railway/railway.ts`](./.railway/railway.ts) and delete the + agent's `NOTION_MCP_URL` / `NOTION_MCP_AUTH_TOKEN` lines; the agent still runs (chat, UI, and — + with `TAVILY_API_KEY` — web research). - **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project + channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. -Non-secret config (the agent's `OPENAI_MODEL`, defaulting to `gpt-5.5`, and the inter-service -URLs/ports) is set for you in [`.railway/railway.ts`](./.railway/railway.ts) — override -`OPENAI_MODEL` in the `agent` service's *Variables* if you want a different model. +The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). +The agent's model defaults to `gpt-5.5`; to use a different model, set `OPENAI_MODEL` in the +`agent` service's *Variables* (the IaC doesn't manage it, so your choice survives re-applies). Applying the config creates the services and their wiring; **KiteBot goes live only once the secrets are set and the `channel` service connects** — that's when your Intelligence dashboard diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md index 940304f..ec7bbb2 100644 --- a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -8,10 +8,25 @@ **Tech Stack:** Railway IaC (`railway` npm SDK v3.5.7, `railway/iac`), TypeScript, nixpacks (agent + node services), the existing `pnpm channel` / `pnpm notion-mcp` scripts and the `agent/` uv project. +> **Implementation note (updated after code review).** The shipped implementation diverged from the +> first-draft snippets below; the code blocks in this plan have been updated to match what landed. +> Key changes made during CR, all reflected in the authoritative [`.railway/railway.ts`](../../../.railway/railway.ts): +> - Services reached over private networking bind `::` (not `0.0.0.0`) — Railway private DNS is IPv6. +> The `notion-mcp` sidecar's launcher (`scripts/start-notion-mcp.ts`) gained a `NOTION_MCP_HOST` arg +> (default `127.0.0.1`) which the IaC sets to `::`. +> - Ports are pinned (`agent` `PORT=8123`, `notion-mcp` `NOTION_MCP_PORT=3001`) and dialed via +> `${{svc.}}` so listen/dial ports always agree. +> - **No `agent/railway.toml`** — Railway forbids a service being managed by both IaC and +> config-as-code, so the agent's build+deploy config lives entirely in the IaC (Task 2 changed). +> - `OPENAI_MODEL` is **not** managed by the IaC (the agent defaults to `gpt-5.5`), so a UI override +> survives `config apply`. `NOTION_MCP_AUTH_TOKEN` is set once on `notion-mcp` and referenced by the +> agent. `tsx` + `dotenv` + `@notionhq/notion-mcp-server` moved to `dependencies` (runtime-required). + ## Global Constraints - Secrets are NEVER written into `.railway/railway.ts` or any committed file — declared via `preserve()` and set by the deployer in Railway. Only non-secret wiring/defaults are literal. - Reference-variable wiring uses literal Railway `${{service.VAR}}` strings in env values (resolved by Railway at deploy) — service names: `agent`, `notion-mcp`, `channel`. -- Model default stays `gpt-5.5`; channel name default `kitebot` (consistent with Phases 1–2). +- Model default stays `gpt-5.5` (via the agent's own default, not pinned in the IaC — so it stays + overridable); channel name default `kitebot` (consistent with Phases 1–2). - Do not disturb the Phase-1/2 gates: `pnpm check-types`, `pnpm test`, and `agent` pytest must stay green. `.railway/` is outside the root tsconfig `include`, so it won't affect `pnpm check-types`. --- @@ -25,50 +40,60 @@ **Interfaces:** - Produces: a `.railway/railway.ts` that `railway config plan/apply` consumes; 3 services named `agent`, `notion-mcp`, `channel`. -- [ ] **Step 1: Add the `railway` SDK devDependency** +- [ ] **Step 1: Add the `railway` SDK devDependency (and reclassify runtime deps)** Run: `pnpm add -D railway@^3.5.7` Expected: adds `railway` to `devDependencies`; `pnpm-lock.yaml` updates; resolves cleanly. +Also move `tsx`, `dotenv`, and `@notionhq/notion-mcp-server` from `devDependencies` to +`dependencies` — the `channel` and `notion-mcp` services run them at runtime (`tsx app/managed.ts`, +`import "dotenv/config"`, and the sidecar binary), so a production-only install must include them. -- [ ] **Step 2: Create `.railway/railway.ts`** +- [ ] **Step 2: Create `.railway/railway.ts`** (final, as shipped) ```ts import { defineRailway, github, preserve, project, service } from "railway/iac"; -// KiteBot on CopilotKit Intelligence — one-click Railway topology. -// Three services build from this repo; the Python agent uses rootDirectory "agent". -// Inter-service URLs use Railway reference variables (${{svc.VAR}}), resolved at -// deploy over private networking. SECRETS are declared with preserve() so applying -// never clobbers deployer-set values — set their actual values in the Railway UI -// (see README "Deploy to Railway"). +// KiteBot on CopilotKit Intelligence — one-click Railway topology. See the file's +// own header for the two topology invariants (pinned ports; bind :: for private +// networking). SECRETS are declared with preserve(); non-secret wiring is literal. const REPO = "CopilotKit/OpenTag"; export default defineRailway(() => { - // Notion MCP sidecar — streamable-HTTP MCP server on $PORT. + // Notion MCP sidecar. Pin NOTION_MCP_PORT (the launcher binds it, not $PORT) and + // NOTION_MCP_HOST=:: (the upstream server defaults to 127.0.0.1, unreachable + // across containers). Optional feature; REQUIRES its two tokens if deployed. const notionMcp = service("notion-mcp", { source: github(REPO), start: "pnpm notion-mcp", + deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5 }, env: { - // secrets (set in Railway UI): + NOTION_MCP_PORT: "3001", + NOTION_MCP_HOST: "::", NOTION_TOKEN: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), }, }); - // Python deep-research agent — deepagents over AG-UI (uvicorn, /health). + // Python deep-research agent. Build+deploy config lives here (single source of + // truth) — NO agent/railway.toml. Bind :: for private networking; pin PORT. const agent = service("agent", { source: github(REPO, { rootDirectory: "agent" }), - start: "uvicorn main:app --host 0.0.0.0 --port $PORT", - healthcheck: "/health", + build: { builder: "NIXPACKS" }, + deploy: { + startCommand: "uvicorn main:app --host :: --port ${PORT:-8123}", + healthcheckPath: "/health", + healthcheckTimeout: 300, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, env: { - OPENAI_MODEL: "gpt-5.5", - // internal research source (optional Notion), wired to the sidecar: + PORT: "8123", + // OPENAI_MODEL intentionally NOT set (agent defaults to gpt-5.5; stays overridable). NOTION_MCP_URL: - "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.PORT}}/mcp", - // secrets (set in Railway UI): OPENAI_API_KEY required; others optional: + "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", + NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", OPENAI_API_KEY: preserve(), TAVILY_API_KEY: preserve(), - NOTION_MCP_AUTH_TOKEN: preserve(), LINEAR_API_KEY: preserve(), }, }); @@ -78,10 +103,8 @@ export default defineRailway(() => { source: github(REPO), start: "pnpm channel", env: { - // brain: points at the agent service over private networking: AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", INTELLIGENCE_CHANNEL_NAME: "kitebot", - // secrets (set in Railway UI): INTELLIGENCE_GATEWAY_WS_URL: preserve(), INTELLIGENCE_API_KEY: preserve(), INTELLIGENCE_ORG_ID: preserve(), @@ -113,38 +136,38 @@ git commit -m "feat(deploy): Railway IaC — agent + notion-mcp + channel servic --- -## Task 2: Per-service build config for the agent +## Task 2: Agent build/deploy config (in the IaC) + sidecar host binding + +> **Changed during CR.** The original plan created `agent/railway.toml`. That is wrong: Railway +> forbids a service being managed by both IaC and config-as-code, and `railway config plan` errors +> when it finds a `railway.toml` for an IaC-managed service. So the agent's build+deploy config +> lives entirely in `.railway/railway.ts` (Task 1's `build` + `deploy` blocks), and there is **no** +> `agent/railway.toml`. **Files:** -- Create: `agent/railway.toml` +- Modify: `scripts/start-notion-mcp.ts` (add `NOTION_MCP_HOST` so the sidecar can bind `::` on Railway) **Interfaces:** -- Consumes: nothing. Produces: a per-service config Railway reads if a deployer configures the `agent` service with config path `/agent/railway.toml` instead of (or alongside) IaC. +- Consumes: the `agent` service's `build`/`deploy` config declared in Task 1. Produces: a sidecar + launcher that binds a configurable host. -- [ ] **Step 1: Create `agent/railway.toml`** (patterned on the deep-agents showcase; nixpacks builds the `uv` project) +- [ ] **Step 1: Add a `NOTION_MCP_HOST` arg to `scripts/start-notion-mcp.ts`** -```toml -[build] -builder = "nixpacks" +Validate it like the existing `NOTION_MCP_PORT` (it's passed to a `shell: true` spawn), default to +`127.0.0.1` (preserves local behavior = the upstream server's own default), and pass it as +`--host ` to the spawned `@notionhq/notion-mcp-server`. The IaC's `notion-mcp` service sets +`NOTION_MCP_HOST: "::"` so on Railway the sidecar is reachable over the IPv6 private network. -[deploy] -startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8123}" -healthcheckPath = "/health" -healthcheckTimeout = 300 -restartPolicyType = "ON_FAILURE" -restartPolicyMaxRetries = 5 -``` - -- [ ] **Step 2: Sanity-check TOML validity** +- [ ] **Step 2: Confirm no `agent/railway.toml` exists** -Run: `pnpm exec node -e "const fs=require('fs');const s=fs.readFileSync('agent/railway.toml','utf8');if(!/builder\s*=\s*\"nixpacks\"/.test(s)||!/healthcheckPath\s*=\s*\"\/health\"/.test(s))throw new Error('bad toml');console.log('railway.toml ok')"` -Expected: `railway.toml ok`. +Run: `test ! -e agent/railway.toml && echo "no toml (correct)"` +Expected: `no toml (correct)`. -- [ ] **Step 3: Commit** +- [ ] **Step 3: Commit** (folded into the CR fix commit) ```bash -git add agent/railway.toml -git commit -m "feat(deploy): agent/railway.toml (nixpacks + uvicorn + /health)" +git add scripts/start-notion-mcp.ts +git commit -m "fix(deploy): sidecar binds NOTION_MCP_HOST (:: on Railway)" ``` --- @@ -169,9 +192,9 @@ Insert a section covering, accurately to the 3 services: railway config apply # provisions agent + notion-mcp + channel from .railway/railway.ts ``` - **Set the secrets** (Railway → each service → Variables) — grouped checklist: - - `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional — enables web research), `NOTION_MCP_AUTH_TOKEN` (must match `notion-mcp`), `LINEAR_API_KEY` (optional). - - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (same value as `agent`). - - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project/channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. + - `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional — enables web research), `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is referenced from `notion-mcp`, not set here.) + - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (both required for the service to start; the agent reads the auth token via a reference variable). To skip Notion, remove this service from `resources` and the agent's `NOTION_MCP_URL`/`NOTION_MCP_AUTH_TOKEN`. + - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence project/channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. The agent's `OPENAI_MODEL` (default `gpt-5.5`) can be overridden in the `agent` service's Variables. - **"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo as a Railway template (Railway dashboard → Templates → New, or `railway` template flow) — that requires your Railway account — then add the generated button: `[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/)` - **Note:** applying the IaC creates the services + wiring; KiteBot goes live only once the secrets are set and the channel connects (dashboard flips *Waiting for runtime → live*). diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index 01abc55..8a159c7 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -69,14 +69,19 @@ declares/preserves them, never contains values):** with the three `service(...)` definitions, `rootDirectory`, build/start/health, the reference-variable wiring above, non-secret env, and secrets via `preserve()` (so applying doesn't clobber deployer-set values). -- **`agent/railway.toml`** (optional per-service belt) — nixpacks + uvicorn + `/health`, - patterned on the showcase, in case a deployer uses per-service config instead of IaC. +- **`scripts/start-notion-mcp.ts`** (modified) — a `NOTION_MCP_HOST` arg (default `127.0.0.1`, + set to `::` by the IaC) so the sidecar binds all interfaces and is reachable over Railway's + IPv6 private network. There is **no** `agent/railway.toml`: Railway forbids a service being + managed by both IaC and config-as-code, so the agent's build+deploy config lives only in + `.railway/railway.ts`. - **`README.md` "Deploy to Railway"** — two documented paths: (a) `railway login && railway link && railway config apply` (IaC), and (b) publish a Railway template from the repo → get a "Deploy on Railway" button (steps + the button markdown, noting it needs the deployer's Railway account). Plus the full **secrets checklist** grouped by service. -- **`railway` devDependency** (or documented `npx`) so `.railway/railway.ts` type-resolves and - `railway config plan` works. +- **Dependency changes in `package.json`** — add the `railway` devDependency (so + `.railway/railway.ts` type-resolves and `railway config plan` works) and move `tsx`, `dotenv`, + and `@notionhq/notion-mcp-server` to `dependencies` (the deployed `channel`/`notion-mcp` + services run them, so a production-only install must include them). ## Verification (definition of done) diff --git a/package.json b/package.json index 36c213a..7a1a65f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@copilotkit/channels": "^0.1.1", + "@notionhq/notion-mcp-server": "^2.2.1", "@copilotkit/channels-discord": "^0.0.3", "@copilotkit/channels-intelligence": "^0.1.1", "@copilotkit/channels-slack": "^0.1.2", @@ -38,7 +39,6 @@ "zod": "^3.25.76" }, "devDependencies": { - "@notionhq/notion-mcp-server": "^2.2.1", "@types/node": "^22.10.0", "railway": "^3.5.7", "typescript": "^5.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ffd13b..f96f55d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@copilotkit/runtime': specifier: ^1.62.3 version: 1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76)) + '@notionhq/notion-mcp-server': + specifier: ^2.2.1 + version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) '@slack/bolt': specifier: ^4.2.0 version: 4.7.3(@types/express@5.0.6) @@ -63,9 +66,6 @@ importers: specifier: ^3.25.76 version: 3.25.76 devDependencies: - '@notionhq/notion-mcp-server': - specifier: ^2.2.1 - version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) '@types/node': specifier: ^22.10.0 version: 22.20.1 diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index 29d5260..5a94e8a 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -50,6 +50,22 @@ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) { } const port = String(portNumber); +// Host the sidecar binds to. Defaults to loopback for local dev (matches the +// upstream server's own default). On Railway the `notion-mcp` service sets +// NOTION_MCP_HOST=:: so the sidecar listens on all interfaces and is reachable +// over Railway's (IPv6) private network — without it the server binds 127.0.0.1 +// and the agent's cross-container connection is refused. Validated because it's +// passed to a `shell: true` spawn below. +const rawHost = process.env["NOTION_MCP_HOST"]; +if (rawHost !== undefined && !/^[A-Za-z0-9.:_-]+$/.test(rawHost)) { + console.error( + `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address (letters, ` + + `digits, ".", ":", "_", "-"), got: ${JSON.stringify(rawHost)}`, + ); + process.exit(1); +} +const host = rawHost ?? "127.0.0.1"; + // Notion's REST API requires a `Notion-Version` header. Authenticate via // OPENAPI_MCP_HEADERS (carrying BOTH Authorization and Notion-Version) rather // than NOTION_TOKEN: when NOTION_TOKEN is set the server builds its own auth @@ -79,6 +95,8 @@ const child = spawn( "@notionhq/notion-mcp-server", "--transport", "http", + "--host", + host, "--port", port, ], From 7dee84fcece18f1ec30944508b2998fa53674c4f Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:01:39 -0700 Subject: [PATCH 08/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=203=20?= =?UTF-8?q?=E2=80=94=20sidecar=20shutdown/version,=20host=20validation,=20?= =?UTF-8?q?invariant=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/start-notion-mcp.ts: - Stop injecting a global Notion-Version header: @notionhq/notion-mcp-server >=2.4 sources the API version per-operation from its OpenAPI spec, and a globally-configured Notion-Version OVERRIDES those defaults (verified in the installed 2.4.1 http-client.buildDefaultHeaders), pinning every call to a 3-year-old version and breaking newer endpoints. NOTION_VERSION still forces one if needed. - Exit handler distinguishes a forwarded shutdown (SIGINT/SIGTERM → exit 0) from an abnormal signal death (→ exit 1), so a clean stop no longer trips the notion-mcp ON_FAILURE restart / reads as a crash in logs. - Add a SIGKILL escalation timeout on shutdown (child may ignore the signal, or shell:true may not propagate it). - Tighten NOTION_MCP_HOST validation to reject a leading '-' (flag-injection shape). - Correct the header docblock to describe the actual inbound/outbound token mapping. .railway/railway.ts: - Leave INTELLIGENCE_CHANNEL_NAME unmanaged (app/managed.ts defaults it to 'kitebot') so a UI override survives config apply — same pattern as OPENAI_MODEL. - Fix model-default citation to agent/agent.py. agent/main.py: default SERVER_HOST to :: so the pnpm-agent/python-main path also honors the IPv6 private-networking invariant (dual-stack; local still works). docs: sync spec/plan to the shipped file (resources order, channel-name note). Co-Authored-By: Claude Opus 4.8 --- .railway/railway.ts | 8 +- README.md | 8 +- agent/main.py | 6 +- .../2026-07-16-kitebot-railway-oneclick.md | 3 +- ...6-07-16-kitebot-railway-oneclick-design.md | 2 +- scripts/start-notion-mcp.ts | 76 +++++++++++++------ 6 files changed, 71 insertions(+), 32 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index 20cfe30..a6e55ed 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -78,7 +78,7 @@ export default defineRailway(() => { "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp", NOTION_MCP_AUTH_TOKEN: "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}", // OPENAI_MODEL is intentionally NOT set here: the agent defaults to - // gpt-5.5 (agent/tools.py), and leaving it unmanaged means a deployer can + // gpt-5.5 (agent/agent.py), and leaving it unmanaged means a deployer can // override it in the Railway UI without a later `config apply` clobbering // the change. Set OPENAI_MODEL in the agent service's Variables to change it. // @@ -97,7 +97,11 @@ export default defineRailway(() => { // brain: points at the agent service over private networking. The agent // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", - INTELLIGENCE_CHANNEL_NAME: "kitebot", + // INTELLIGENCE_CHANNEL_NAME is intentionally NOT set here: app/managed.ts + // already defaults it to "kitebot", and leaving it unmanaged means a + // deployer can set their own channel name in the Railway UI without a + // later `config apply` clobbering it. Set it in the channel service's + // Variables if your Intelligence channel is named something else. // secrets (set in Railway UI): INTELLIGENCE_GATEWAY_WS_URL: preserve(), INTELLIGENCE_API_KEY: preserve(), diff --git a/README.md b/README.md index f20309d..5efa013 100644 --- a/README.md +++ b/README.md @@ -191,11 +191,13 @@ railway config apply # provisions agent + notion-mcp + channel from .rai with `TAVILY_API_KEY` — web research). - **`channel`** — `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID` (from your CopilotKit Intelligence - project + channel). `INTELLIGENCE_CHANNEL_NAME` defaults to `kitebot`. + project + channel). The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts). -The agent's model defaults to `gpt-5.5`; to use a different model, set `OPENAI_MODEL` in the -`agent` service's *Variables* (the IaC doesn't manage it, so your choice survives re-applies). +Two values are left unmanaged by the IaC so a UI override survives `railway config apply`: the +agent's `OPENAI_MODEL` (defaults to `gpt-5.5`) and the channel's `INTELLIGENCE_CHANNEL_NAME` +(defaults to `kitebot`). Set either in that service's *Variables* to change it — e.g. set +`INTELLIGENCE_CHANNEL_NAME` if your Intelligence channel isn't named `kitebot`. Applying the config creates the services and their wiring; **KiteBot goes live only once the secrets are set and the `channel` service connects** — that's when your Intelligence dashboard diff --git a/agent/main.py b/agent/main.py index 884d32a..45dd592 100644 --- a/agent/main.py +++ b/agent/main.py @@ -74,7 +74,11 @@ def main(): """Run the server with uvicorn""" import uvicorn - host = os.getenv("SERVER_HOST", "0.0.0.0") + # Default to :: (all interfaces, dual-stack) so this path is reachable over + # Railway's IPv6 private network too — matching the invariant the Railway + # startCommand enforces via `--host ::`. Binding :: on Linux/macOS accepts + # both IPv6 and IPv4, so local dev still works. Override with SERVER_HOST. + host = os.getenv("SERVER_HOST", "::") # Honor Railway's injected $PORT first, falling back to SERVER_PORT for local dev raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT", "8123") try: diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md index ec7bbb2..7113158 100644 --- a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -104,7 +104,8 @@ export default defineRailway(() => { start: "pnpm channel", env: { AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", - INTELLIGENCE_CHANNEL_NAME: "kitebot", + // INTELLIGENCE_CHANNEL_NAME left unmanaged (app/managed.ts defaults it to + // "kitebot") so a UI override survives config apply, like OPENAI_MODEL. INTELLIGENCE_GATEWAY_WS_URL: preserve(), INTELLIGENCE_API_KEY: preserve(), INTELLIGENCE_ORG_ID: preserve(), diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index 8a159c7..e6dc9f6 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -65,7 +65,7 @@ declares/preserves them, never contains values):** ## Artifacts -- **`.railway/railway.ts`** — `defineRailway` returning `project("kitebot", { resources: [agent, notionMcp, channel] })` +- **`.railway/railway.ts`** — `defineRailway` returning `project("kitebot", { resources: [notionMcp, agent, channel] })` with the three `service(...)` definitions, `rootDirectory`, build/start/health, the reference-variable wiring above, non-secret env, and secrets via `preserve()` (so applying doesn't clobber deployer-set values). diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index 5a94e8a..040d4d6 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -2,12 +2,14 @@ * Starts the official Notion MCP server as a Streamable-HTTP sidecar for * the triage agent (see `runtime.ts`). Run with `pnpm notion-mcp`. * - * Why a launcher instead of a raw npm script: the Notion server takes its - * Notion integration secret via the `NOTION_TOKEN` env var and its HTTP - * transport bearer via `AUTH_TOKEN` (or `--auth-token`). We keep the - * example's env in a single `.env` (`NOTION_TOKEN` + `NOTION_MCP_AUTH_TOKEN`) - * and map it here, so this works identically on Windows/macOS/Linux without - * shell-specific env interpolation. + * Why a launcher instead of a raw npm script: the two `.env` values need to be + * mapped to the two DIFFERENT things the Notion server expects — the Notion + * integration secret (`NOTION_TOKEN`) becomes the OUTBOUND `Authorization: + * Bearer` the server sends to Notion's API (passed via `OPENAPI_MCP_HEADERS`, + * see below), and `NOTION_MCP_AUTH_TOKEN` becomes the server's INBOUND HTTP + * transport bearer (`AUTH_TOKEN` / `--auth-token`) that the agent presents to + * reach this sidecar. Doing the mapping here works identically on + * Windows/macOS/Linux without shell-specific env interpolation. */ import "dotenv/config"; import { spawn } from "node:child_process"; @@ -57,30 +59,37 @@ const port = String(portNumber); // and the agent's cross-container connection is refused. Validated because it's // passed to a `shell: true` spawn below. const rawHost = process.env["NOTION_MCP_HOST"]; -if (rawHost !== undefined && !/^[A-Za-z0-9.:_-]+$/.test(rawHost)) { +// Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps +// it shell-inert and prevents a leading-"-" value being consumed as a flag by +// the child CLI rather than as the host. +if (rawHost !== undefined && !/^[A-Za-z0-9:][A-Za-z0-9.:_-]*$/.test(rawHost)) { console.error( - `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address (letters, ` + - `digits, ".", ":", "_", "-"), got: ${JSON.stringify(rawHost)}`, + `[notion-mcp] NOTION_MCP_HOST must be a hostname or IP address starting ` + + `with a letter, digit, or ":" (e.g. "::", "0.0.0.0", "localhost"), got: ` + + `${JSON.stringify(rawHost)}`, ); process.exit(1); } const host = rawHost ?? "127.0.0.1"; -// Notion's REST API requires a `Notion-Version` header. Authenticate via -// OPENAPI_MCP_HEADERS (carrying BOTH Authorization and Notion-Version) rather -// than NOTION_TOKEN: when NOTION_TOKEN is set the server builds its own auth -// header and omits the version, so the API rejects every call with -// 400 `missing_version`. We deliberately do NOT pass NOTION_TOKEN below. -const notionVersion = process.env["NOTION_VERSION"] ?? "2022-06-28"; +// Carry the Notion secret as the outbound `Authorization: Bearer` via +// OPENAPI_MCP_HEADERS. We deliberately do NOT set `Notion-Version` here: the +// server (@notionhq/notion-mcp-server ≥ 2.4) sources the API version +// per-operation from its bundled OpenAPI spec, and a globally-configured +// `Notion-Version` header would OVERRIDE those per-operation defaults, pinning +// every call to one (soon-stale) version and breaking newer endpoints. Leaving +// it out lets each operation use the version its schema was written for. (Set +// NOTION_VERSION only if you must force a specific version for an older server.) +const forcedVersion = process.env["NOTION_VERSION"]; const openApiHeaders = JSON.stringify({ Authorization: `Bearer ${notionToken}`, - "Notion-Version": notionVersion, + ...(forcedVersion ? { "Notion-Version": forcedVersion } : {}), }); // OPENAPI_MCP_HEADERS is the sole Notion auth source. NOTION_TOKEN must be // absent from the child env (dotenv loaded it into process.env, so delete it -// after the spread) — if present, the server ignores OPENAPI_MCP_HEADERS and -// drops the Notion-Version header. +// after the spread) — if present, the server would build its own auth header +// from it and ignore OPENAPI_MCP_HEADERS. const childEnv: NodeJS.ProcessEnv = { ...process.env, OPENAPI_MCP_HEADERS: openApiHeaders, @@ -110,10 +119,20 @@ const child = spawn( }, ); -// code is null when the child died from a signal (SIGKILL/OOM/SIGSEGV, etc.) -// rather than a normal exit; map that to a non-zero status so a supervisor -// or healthcheck can detect the crash instead of seeing a false "success". -child.on("exit", (code) => process.exit(code ?? 1)); +// Set when WE forward a shutdown signal (below), so the exit handler can tell a +// clean, operator/platform-initiated stop from an abnormal death. +let shuttingDown = false; +child.on("exit", (code, signal) => { + // Normal exit: propagate the child's own status. + if (code !== null) process.exit(code); + // Died by a signal we forwarded (SIGINT/SIGTERM) — a deliberate shutdown, so + // report success and don't trip the service's ON_FAILURE restart policy. + if (shuttingDown) process.exit(0); + // Died by a signal we did NOT initiate (SIGKILL/OOM/SIGSEGV) — a real crash; + // exit non-zero so a supervisor/healthcheck sees the failure. + console.error(`[notion-mcp] sidecar terminated by signal ${signal ?? "unknown"}`); + process.exit(1); +}); // Without this, a failed spawn (e.g. `npx` not on PATH -> ENOENT, or EACCES) // surfaces as an uncaught 'error' event with a raw Node stack trace instead // of the clean, actionable messages this script prints for every other @@ -122,5 +141,14 @@ child.on("error", (err) => { console.error("[notion-mcp] failed to start the sidecar:", err.message); process.exit(1); }); -process.on("SIGINT", () => child.kill("SIGINT")); -process.on("SIGTERM", () => child.kill("SIGTERM")); +// Forward shutdown signals to the child, then escalate to SIGKILL if it hasn't +// exited within a grace window — the child may be slow/ignore the signal, or +// (under shell:true) the signal may reach the shell wrapper but not propagate +// to the underlying server. `.unref()` so this timer never keeps us alive. +const shutdown = (sig: NodeJS.Signals) => { + shuttingDown = true; + child.kill(sig); + setTimeout(() => child.kill("SIGKILL"), 5000).unref(); +}; +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); From ba78ffc364c0b48823385c46cdd7f25adbe95216 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:13:23 -0700 Subject: [PATCH 09/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=204=20?= =?UTF-8?q?=E2=80=94=20shutdown=20exit=20ordering,=20dep=20floor,=20docs/c?= =?UTF-8?q?omments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/start-notion-mcp.ts: - Exit handler: check shuttingDown BEFORE the exit code, so a forwarded-signal shutdown that shell:true surfaces as a numeric 128+signum code (e.g. 143) still reports success and doesn't trip the notion-mcp ON_FAILURE restart policy. - Treat an empty NOTION_MCP_PORT/NOTION_MCP_HOST ('' from a bare .env line) as unset → default, matching agent/main.py; clearer non-numeric port message. - Correct the NOTION_TOKEN-deletion comment: OPENAPI_MCP_HEADERS takes precedence (server checks it before NOTION_TOKEN), so the delete is defensive cleanup. - Note both NOTION_MCP_URL consumers (runtime.ts and the Python agent). package.json: raise @notionhq/notion-mcp-server floor to ^2.4.0 — the launcher relies on >=2.4 per-operation Notion-Version sourcing; ^2.2.1 allowed sub-2.4. agent/main.py: make CORS origins env-driven (CORS_ALLOW_ORIGINS, default '*') so a public exposure can be locked down; clarify the local-dev-only PORT block comment. docs: README adds LINEAR_API_KEY to the .env example and clarifies the deploy flow (pnpm install first so the CLI can evaluate the config; railway init to create); spec drops the stale agent NOTION_MCP_AUTH_TOKEN secret and the OPENAI_MODEL/ INTELLIGENCE_CHANNEL_NAME wiring lines (now unmanaged); plan deploy block synced. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +++- agent/main.py | 17 +++++++-- .../2026-07-16-kitebot-railway-oneclick.md | 5 ++- ...6-07-16-kitebot-railway-oneclick-design.md | 14 ++++--- package.json | 2 +- pnpm-lock.yaml | 2 +- scripts/start-notion-mcp.ts | 37 +++++++++++-------- 7 files changed, 55 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 5efa013..64e4279 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ CopilotKit Intelligence project for Intelligence mode. Step-by-step in OPENAI_API_KEY=sk-... # the TS runtime (runtime.ts) uses OpenAI's Responses API for web search; # the Python agent/ uses Tavily instead (see "Deep research" below) +# Optional integrations (see setup.md): +LINEAR_API_KEY=lin_api_... # optional — lets the agent file Linear tickets + # Self-hosted mode: SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... @@ -171,10 +174,11 @@ so you don't set them by hand. **Deploy via Infrastructure-as-Code (recommended):** ```bash +pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts npm i -g @railway/cli # or: brew install railway railway login -railway link # create or select a Railway project -railway config apply # provisions agent + notion-mcp + channel from .railway/railway.ts +railway init # create a new Railway project (or `railway link` to select an existing one) +railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts ``` **Set the secrets** (Railway → each service → *Variables*). The IaC declares them with diff --git a/agent/main.py b/agent/main.py index 45dd592..96bd61c 100644 --- a/agent/main.py +++ b/agent/main.py @@ -23,11 +23,17 @@ version="0.1.0", ) -# Enable CORS for frontend communication -# Using "*" for demo purposes - allows any origin including localhost and Railway deployments +# Enable CORS for frontend communication. Defaults to "*" (any origin) for +# local/demo use; on Railway the agent is reached only over private networking +# by the channel service, so this is not a credential vector (allow_credentials +# is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it +# down if the service is ever exposed publicly. +_cors_origins = [ + o.strip() for o in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if o.strip() +] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_cors_origins, allow_credentials=False, allow_methods=["*"], allow_headers=["*"], @@ -79,7 +85,10 @@ def main(): # startCommand enforces via `--host ::`. Binding :: on Linux/macOS accepts # both IPv6 and IPv4, so local dev still works. Override with SERVER_HOST. host = os.getenv("SERVER_HOST", "::") - # Honor Railway's injected $PORT first, falling back to SERVER_PORT for local dev + # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn + # main:app` directly, so this block is bypassed and the port comes from the + # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so + # `pnpm agent` matches that Railway behavior. raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT", "8123") try: port = int(raw_port) diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md index 7113158..e3e4dfa 100644 --- a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -187,10 +187,11 @@ Insert a section covering, accurately to the 3 services: - **What deploys:** 3 services from this repo — `agent` (Python deep-research, `rootDirectory: agent`), `notion-mcp` (`pnpm notion-mcp`), `channel` (`pnpm channel`); the `channel` reaches `agent` and `agent` reaches `notion-mcp` over Railway private networking (auto-wired by the IaC). - **Deploy via IaC (recommended):** ```bash + pnpm install # installs the `railway` SDK the CLI uses to evaluate .railway/railway.ts npm i -g @railway/cli # or: brew install railway railway login - railway link # create/select a Railway project - railway config apply # provisions agent + notion-mcp + channel from .railway/railway.ts + railway init # create a new project (or `railway link` to select an existing one) + railway config apply # from the repo root: provisions agent + notion-mcp + channel from .railway/railway.ts ``` - **Set the secrets** (Railway → each service → Variables) — grouped checklist: - `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional — enables web research), `LINEAR_API_KEY` (optional). (`NOTION_MCP_AUTH_TOKEN` is referenced from `notion-mcp`, not set here.) diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index e6dc9f6..11c0cd7 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -51,14 +51,18 @@ All three build from this repo (`source: github("CopilotKit/OpenTag")`), differi **Wiring (env in `railway.ts`, non-secret refs):** - `channel.env.AGENT_URL = "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/"` (agent pins `PORT: "8123"`) - `agent.env.NOTION_MCP_URL = "http://${{notion-mcp.RAILWAY_PRIVATE_DOMAIN}}:${{notion-mcp.NOTION_MCP_PORT}}/mcp"` -- `agent.env.OPENAI_MODEL = "gpt-5.5"` (and any other non-secret defaults) -- `channel.env.INTELLIGENCE_CHANNEL_NAME = "kitebot"` (non-secret default) +- `agent.env.NOTION_MCP_AUTH_TOKEN = "${{notion-mcp.NOTION_MCP_AUTH_TOKEN}}"` (single source of truth — set once on `notion-mcp`) +- `OPENAI_MODEL` and `INTELLIGENCE_CHANNEL_NAME` are intentionally **not** set in `railway.ts`: the + code already defaults them (`gpt-5.5` in `agent/agent.py`, `"kitebot"` in `app/managed.ts`), and + leaving them unmanaged lets a deployer override them in the Railway UI without a later + `config apply` clobbering the change. **Secrets (deployer sets in the Railway UI; NOT stored in `railway.ts` — the IaC file declares/preserves them, never contains values):** -- `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional), `NOTION_MCP_AUTH_TOKEN`, - optional `LINEAR_API_KEY`. -- `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (shared with `agent`). +- `agent`: `OPENAI_API_KEY` (required), `TAVILY_API_KEY` (optional), optional `LINEAR_API_KEY`. + (`NOTION_MCP_AUTH_TOKEN` is **not** deployer-set here — the agent reads it via a reference + variable from `notion-mcp`.) +- `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (the agent references this value). - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`. (`INTELLIGENCE_CHANNEL_NAME` is **not** a secret — it's a non-secret default of `"kitebot"` set in `railway.ts`.) diff --git a/package.json b/package.json index 7a1a65f..4aa730d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@copilotkit/channels": "^0.1.1", - "@notionhq/notion-mcp-server": "^2.2.1", + "@notionhq/notion-mcp-server": "^2.4.0", "@copilotkit/channels-discord": "^0.0.3", "@copilotkit/channels-intelligence": "^0.1.1", "@copilotkit/channels-slack": "^0.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f96f55d..383baff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: ^1.62.3 version: 1.62.3(@cfworker/json-schema@4.1.1)(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@langchain/langgraph-sdk@1.9.27(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1)))(@opentelemetry/api@1.9.1)(langchain@1.5.3(@langchain/core@1.2.3(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(@opentelemetry/api@1.9.1)(openai@6.47.0(ws@8.21.1)(zod@3.25.76))(ws@8.21.1))(openai@6.47.0(ws@8.21.1)(zod@3.25.76)) '@notionhq/notion-mcp-server': - specifier: ^2.2.1 + specifier: ^2.4.0 version: 2.4.1(@cfworker/json-schema@4.1.1)(js-yaml@4.3.0) '@slack/bolt': specifier: ^4.2.0 diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index 040d4d6..d0cf721 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -32,14 +32,16 @@ if (!notionToken) { process.exit(1); } -// Port the sidecar listens on. Must agree with NOTION_MCP_URL in runtime.ts -// (default http://127.0.0.1:3001/mcp). Validated up front because it's -// passed as a `--port` arg to a `shell: true` spawn below — an unvalidated -// value with spaces/shell metacharacters could mangle or inject the command. -const rawPort = process.env["NOTION_MCP_PORT"]; +// Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by +// runtime.ts (TS backend) AND by the Python agent/ backend on Railway — both +// default to http://127.0.0.1:3001/mcp. Validated up front because it's passed +// as a `--port` arg to a `shell: true` spawn below — an unvalidated value with +// spaces/shell metacharacters could mangle or inject the command. An empty +// string (a bare `NOTION_MCP_PORT=` in .env) is treated as unset → default. +const rawPort = process.env["NOTION_MCP_PORT"] || undefined; if (rawPort !== undefined && !/^\d+$/.test(rawPort)) { console.error( - `[notion-mcp] NOTION_MCP_PORT must be an integer between 1 and 65535, got: ${JSON.stringify(rawPort)}`, + `[notion-mcp] NOTION_MCP_PORT must be a positive integer (1–65535), got: ${JSON.stringify(rawPort)}`, ); process.exit(1); } @@ -58,7 +60,7 @@ const port = String(portNumber); // over Railway's (IPv6) private network — without it the server binds 127.0.0.1 // and the agent's cross-container connection is refused. Validated because it's // passed to a `shell: true` spawn below. -const rawHost = process.env["NOTION_MCP_HOST"]; +const rawHost = process.env["NOTION_MCP_HOST"] || undefined; // Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps // it shell-inert and prevents a leading-"-" value being consumed as a flag by // the child CLI rather than as the host. @@ -86,10 +88,12 @@ const openApiHeaders = JSON.stringify({ ...(forcedVersion ? { "Notion-Version": forcedVersion } : {}), }); -// OPENAPI_MCP_HEADERS is the sole Notion auth source. NOTION_TOKEN must be -// absent from the child env (dotenv loaded it into process.env, so delete it -// after the spread) — if present, the server would build its own auth header -// from it and ignore OPENAPI_MCP_HEADERS. +// OPENAPI_MCP_HEADERS is our Notion auth source. It takes precedence anyway +// (the server's parseHeadersFromEnv checks OPENAPI_MCP_HEADERS before falling +// back to NOTION_TOKEN), so deleting NOTION_TOKEN from the child env is +// defensive cleanup: it removes the unused fallback and suppresses the server's +// NOTION_TOKEN startup diagnostic (a /v1/users/me probe). dotenv loaded it into +// process.env, so delete it after the spread. const childEnv: NodeJS.ProcessEnv = { ...process.env, OPENAPI_MCP_HEADERS: openApiHeaders, @@ -123,12 +127,15 @@ const child = spawn( // clean, operator/platform-initiated stop from an abnormal death. let shuttingDown = false; child.on("exit", (code, signal) => { + // A shutdown WE forwarded (SIGINT/SIGTERM) is deliberate — report success + // regardless of how the child surfaced it. Checked FIRST because under + // shell:true the wrapper may report the forwarded signal as a numeric + // 128+signum code (e.g. 143) rather than code===null; either way a clean + // stop must not trip the service's ON_FAILURE restart policy. + if (shuttingDown) process.exit(0); // Normal exit: propagate the child's own status. if (code !== null) process.exit(code); - // Died by a signal we forwarded (SIGINT/SIGTERM) — a deliberate shutdown, so - // report success and don't trip the service's ON_FAILURE restart policy. - if (shuttingDown) process.exit(0); - // Died by a signal we did NOT initiate (SIGKILL/OOM/SIGSEGV) — a real crash; + // Signal death we did NOT initiate (SIGKILL/OOM/SIGSEGV) — a real crash; // exit non-zero so a supervisor/healthcheck sees the failure. console.error(`[notion-mcp] sidecar terminated by signal ${signal ?? "unknown"}`); process.exit(1); From f60a540dd1638b7f08e279028a08004b56fbc831 Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:24:00 -0700 Subject: [PATCH 10/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=205=20?= =?UTF-8?q?=E2=80=94=20supervisor=20semantics,=20POSIX=20process=20groups,?= =?UTF-8?q?=20macOS=20bind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/start-notion-mcp.ts: - Spawn with shell only on Windows (npx.cmd/CVE-2024-27980); on POSIX spawn npx directly + detached, so the server runs in its own process group. Shutdown now signals the whole group (process.kill(-pid)) with a SIGKILL escalation, which reaps the underlying node server instead of just the shell wrapper and also removes the terminal-Ctrl-C race (the detached child no longer receives the terminal's SIGINT directly). - Exit handler: any termination we did NOT initiate is a failure — exit non-zero even on code 0 (e.g. server exits after a bad flag) so Railway's ON_FAILURE restart fires and it's visible, instead of a silent code-0 passthrough. - Header docstring names both consumers (runtime.ts + Python agent; Railway's sole consumer is the Python agent). agent/main.py: - Revert local SERVER_HOST default to 0.0.0.0 — a :: default risked local macOS dev (a :: socket may not accept 127.0.0.1). Railway still binds :: via the startCommand; this __main__ path is local-only. - CORS: treat an empty/blank CORS_ALLOW_ORIGINS as unset -> '*' (an empty allowlist would otherwise block every origin, contradicting the default). docs: README documents the Notion cold-start race + redeploy workaround; spec fixes the INTELLIGENCE_CHANNEL_NAME self-contradiction (it is NOT set in the IaC); package.json dependency ordering tidied. Co-Authored-By: Claude Opus 4.8 --- README.md | 7 +++ agent/main.py | 19 ++++-- ...6-07-16-kitebot-railway-oneclick-design.md | 3 +- package.json | 2 +- scripts/start-notion-mcp.ts | 62 ++++++++++++------- 5 files changed, 61 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 64e4279..863fb72 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,13 @@ Applying the config creates the services and their wiring; **KiteBot goes live o secrets are set and the `channel` service connects** — that's when your Intelligence dashboard flips *Waiting for runtime → live*. +> **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first +> cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in +> which case Notion research is silently unavailable for that deployment (chat, UI, and Tavily +> web research still work). If KiteBot can't reach Notion after the first deploy, **redeploy the +> `agent` service** once `notion-mcp` is up. (A lazy/retrying MCP load would remove this race — +> tracked as a follow-up.) + **"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, then drop the generated button in: diff --git a/agent/main.py b/agent/main.py index 96bd61c..c03a2b4 100644 --- a/agent/main.py +++ b/agent/main.py @@ -28,8 +28,13 @@ # by the channel service, so this is not a credential vector (allow_credentials # is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it # down if the service is ever exposed publicly. +# `... or "*"` so an empty/blank CORS_ALLOW_ORIGINS (e.g. a deployer clearing the +# var to "reset") falls back to the permissive default rather than an empty +# allowlist that would block every origin. _cors_origins = [ - o.strip() for o in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if o.strip() + o.strip() + for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") + if o.strip() ] app.add_middleware( CORSMiddleware, @@ -80,11 +85,13 @@ def main(): """Run the server with uvicorn""" import uvicorn - # Default to :: (all interfaces, dual-stack) so this path is reachable over - # Railway's IPv6 private network too — matching the invariant the Railway - # startCommand enforces via `--host ::`. Binding :: on Linux/macOS accepts - # both IPv6 and IPv4, so local dev still works. Override with SERVER_HOST. - host = os.getenv("SERVER_HOST", "::") + # Local-dev default 0.0.0.0 (IPv4 all-interfaces) — accepts 127.0.0.1/ + # localhost clients on every platform. This __main__ path runs only for + # local `pnpm agent`; on Railway the startCommand binds `--host ::` for the + # IPv6 private network, so this default does not affect the deploy. (Avoid + # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so + # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. + host = os.getenv("SERVER_HOST", "0.0.0.0") # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn # main:app` directly, so this block is bypassed and the port comes from the # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index 11c0cd7..fa2157b 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -65,7 +65,8 @@ declares/preserves them, never contains values):** - `notion-mcp`: `NOTION_TOKEN`, `NOTION_MCP_AUTH_TOKEN` (the agent references this value). - `channel`: `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY`, `INTELLIGENCE_ORG_ID`, `INTELLIGENCE_PROJECT_ID`, `INTELLIGENCE_CHANNEL_ID`. (`INTELLIGENCE_CHANNEL_NAME` is **not** a - secret — it's a non-secret default of `"kitebot"` set in `railway.ts`.) + secret and is **not** set in `railway.ts` — `app/managed.ts` defaults it to `"kitebot"`; a + deployer can override it in the Railway UI, per the Wiring note above.) ## Artifacts diff --git a/package.json b/package.json index 4aa730d..de0de42 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ }, "dependencies": { "@copilotkit/channels": "^0.1.1", - "@notionhq/notion-mcp-server": "^2.4.0", "@copilotkit/channels-discord": "^0.0.3", "@copilotkit/channels-intelligence": "^0.1.1", "@copilotkit/channels-slack": "^0.1.2", @@ -28,6 +27,7 @@ "@copilotkit/channels-ui": "^0.1.1", "@copilotkit/channels-whatsapp": "^0.0.2", "@copilotkit/runtime": "^1.62.3", + "@notionhq/notion-mcp-server": "^2.4.0", "@slack/bolt": "^4.2.0", "@slack/types": "^2.21.1", "@tanstack/ai": "^0.32.0", diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index d0cf721..f8d9994 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -1,6 +1,8 @@ /** - * Starts the official Notion MCP server as a Streamable-HTTP sidecar for - * the triage agent (see `runtime.ts`). Run with `pnpm notion-mcp`. + * Starts the official Notion MCP server as a Streamable-HTTP sidecar for the + * agent backends — the TS runtime (`runtime.ts`) and the Python deep-research + * agent (`agent/`); on Railway the Python agent is the sole consumer (see + * `.railway/railway.ts`). Run with `pnpm notion-mcp`. * * Why a launcher instead of a raw npm script: the two `.env` values need to be * mapped to the two DIFFERENT things the Notion server expects — the Notion @@ -101,6 +103,7 @@ const childEnv: NodeJS.ProcessEnv = { }; delete childEnv["NOTION_TOKEN"]; +const isWindows = process.platform === "win32"; const child = spawn( "npx", [ @@ -115,30 +118,31 @@ const child = spawn( ], { stdio: "inherit", - // shell:true so Windows resolves `npx` -> `npx.cmd`. Node refuses to - // spawn `.cmd`/`.bat` directly without a shell (CVE-2024-27980), which - // otherwise fails with `spawn EINVAL`. - shell: true, + // Windows only: shell:true so `npx` resolves to `npx.cmd`. Node refuses to + // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980) → `spawn EINVAL`. On + // POSIX we spawn `npx` directly (no shell), which both removes the shell + // wrapper entirely and lets `detached` put the server in its own process + // group so we can signal the WHOLE tree on shutdown (see killTree below). + shell: isWindows, + detached: !isWindows, env: childEnv, }, ); // Set when WE forward a shutdown signal (below), so the exit handler can tell a -// clean, operator/platform-initiated stop from an abnormal death. +// clean, operator/platform-initiated stop from an abnormal termination. let shuttingDown = false; child.on("exit", (code, signal) => { - // A shutdown WE forwarded (SIGINT/SIGTERM) is deliberate — report success - // regardless of how the child surfaced it. Checked FIRST because under - // shell:true the wrapper may report the forwarded signal as a numeric - // 128+signum code (e.g. 143) rather than code===null; either way a clean - // stop must not trip the service's ON_FAILURE restart policy. + // A shutdown WE initiated (SIGINT/SIGTERM forwarded below) is clean. if (shuttingDown) process.exit(0); - // Normal exit: propagate the child's own status. - if (code !== null) process.exit(code); - // Signal death we did NOT initiate (SIGKILL/OOM/SIGSEGV) — a real crash; - // exit non-zero so a supervisor/healthcheck sees the failure. - console.error(`[notion-mcp] sidecar terminated by signal ${signal ?? "unknown"}`); - process.exit(1); + // Anything else is an unexpected termination of a long-running server — a + // FAILURE even on code 0 (e.g. the server exits after an unknown flag). Exit + // non-zero and log so Railway's ON_FAILURE restart fires and it's visible; + // preserve the child's own non-zero code when it has one. + console.error( + `[notion-mcp] sidecar exited unexpectedly (code=${code}, signal=${signal ?? "none"}) — treating as failure`, + ); + process.exit(code || 1); }); // Without this, a failed spawn (e.g. `npx` not on PATH -> ENOENT, or EACCES) // surfaces as an uncaught 'error' event with a raw Node stack trace instead @@ -148,14 +152,24 @@ child.on("error", (err) => { console.error("[notion-mcp] failed to start the sidecar:", err.message); process.exit(1); }); -// Forward shutdown signals to the child, then escalate to SIGKILL if it hasn't -// exited within a grace window — the child may be slow/ignore the signal, or -// (under shell:true) the signal may reach the shell wrapper but not propagate -// to the underlying server. `.unref()` so this timer never keeps us alive. +// Signal the whole child tree. On POSIX the child is a detached group leader, so +// process.kill(-pid) reaches npx AND the underlying node server; on Windows we +// fall back to child.kill (no POSIX process groups). Wrapped because the group +// may already be gone (ESRCH) by the time the escalation fires. +const killTree = (sig: NodeJS.Signals) => { + try { + if (!isWindows && child.pid) process.kill(-child.pid, sig); + else child.kill(sig); + } catch { + // already exited — nothing to signal + } +}; +// Forward shutdown signals, then escalate to SIGKILL if the tree hasn't exited +// within a grace window. `.unref()` so this timer never keeps us alive. const shutdown = (sig: NodeJS.Signals) => { shuttingDown = true; - child.kill(sig); - setTimeout(() => child.kill("SIGKILL"), 5000).unref(); + killTree(sig); + setTimeout(() => killTree("SIGKILL"), 5000).unref(); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); From 9a5e9c837df0bf10e7766037a3c928d85551064e Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:33:52 -0700 Subject: [PATCH 11/12] =?UTF-8?q?fix(cr):=20Railway=20CR=20round=206=20?= =?UTF-8?q?=E2=80=94=20env-blank=20normalization,=20configured-source=20wa?= =?UTF-8?q?rnings,=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent/main.py: - CORS: fall back to ['*'] when the resolved allowlist is empty, so a whitespace/comma-only CORS_ALLOW_ORIGINS no longer blocks every origin. - SERVER_HOST / SERVER_PORT: treat set-but-empty as unset (`getenv(x) or default`), matching the convention already used for PORT/CORS. - Route [ERROR] diagnostics to stderr. agent/tools.py: when an internal source is CONFIGURED (token set) but its MCP server times out / is unavailable, warn loudly on stderr that the agent is running WITHOUT those tools — distinguishing a real degradation (e.g. the Railway cold-start race) from a source the operator intentionally left disabled. .railway/railway.ts: give the channel service the same ON_FAILURE restart policy as agent/notion-mcp (parity — don't leave KiteBot silently offline on a crash). scripts/start-notion-mcp.ts: correct the validation comments (`shell: isWindows`, not an unconditional `shell: true`). docs: reconcile the spec's deploy commands with the README (railway init to create a project, or railway link for an existing one). Co-Authored-By: Claude Opus 4.8 --- .railway/railway.ts | 6 +++++ agent/main.py | 18 ++++++++------- agent/tools.py | 22 ++++++++++++++----- ...6-07-16-kitebot-railway-oneclick-design.md | 6 +++-- scripts/start-notion-mcp.ts | 10 +++++---- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index a6e55ed..de623ed 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -93,6 +93,12 @@ export default defineRailway(() => { const channel = service("channel", { source: github(REPO), start: "pnpm channel", + deploy: { + // Parity with agent/notion-mcp: restart the long-running channel host on + // crash instead of leaving KiteBot silently offline. + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, + }, env: { // brain: points at the agent service over private networking. The agent // pins PORT=8123, so ${{agent.PORT}} resolves to the port uvicorn binds. diff --git a/agent/main.py b/agent/main.py index c03a2b4..8b57c5a 100644 --- a/agent/main.py +++ b/agent/main.py @@ -28,14 +28,15 @@ # by the channel service, so this is not a credential vector (allow_credentials # is False). Set CORS_ALLOW_ORIGINS to a comma-separated allowlist to lock it # down if the service is ever exposed publicly. -# `... or "*"` so an empty/blank CORS_ALLOW_ORIGINS (e.g. a deployer clearing the -# var to "reset") falls back to the permissive default rather than an empty -# allowlist that would block every origin. +# Trailing `or ["*"]` so ANY blank CORS_ALLOW_ORIGINS — unset, empty, or a +# whitespace/comma-only value a deployer left while "resetting" — falls back to +# the permissive default rather than an empty allowlist that would block every +# origin. _cors_origins = [ o.strip() for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") if o.strip() -] +] or ["*"] app.add_middleware( CORSMiddleware, allow_origins=_cors_origins, @@ -77,7 +78,7 @@ def health(): print("[SERVER] Deep Research Agent registered at /") except Exception as e: - print(f"[ERROR] Failed to build agent: {e}") + print(f"[ERROR] Failed to build agent: {e}", file=sys.stderr) raise @@ -91,19 +92,20 @@ def main(): # IPv6 private network, so this default does not affect the deploy. (Avoid # defaulting to :: here: on macOS/BSD a `::` socket may not accept IPv4, so # a local client dialing 127.0.0.1 could be refused.) Override with SERVER_HOST. - host = os.getenv("SERVER_HOST", "0.0.0.0") + host = os.getenv("SERVER_HOST") or "0.0.0.0" # Local-dev entrypoint only: on Railway the startCommand runs `uvicorn # main:app` directly, so this block is bypassed and the port comes from the # startCommand's `--port ${PORT:-8123}`. Prefer PORT then SERVER_PORT here so # `pnpm agent` matches that Railway behavior. - raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT", "8123") + raw_port = os.getenv("PORT") or os.getenv("SERVER_PORT") or "8123" try: port = int(raw_port) if not (1 <= port <= 65535): raise ValueError("out of range") except ValueError: print( - f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535' + f'[ERROR] Invalid PORT/SERVER_PORT: "{raw_port}" — must be an integer between 1 and 65535', + file=sys.stderr, ) sys.exit(1) reload = os.getenv("AGENT_RELOAD", "").lower() in ("1", "true", "yes") diff --git a/agent/tools.py b/agent/tools.py index ff390e1..a1fdbd7 100644 --- a/agent/tools.py +++ b/agent/tools.py @@ -9,6 +9,7 @@ """ import os +import sys import asyncio from typing import Any from concurrent.futures import ThreadPoolExecutor @@ -229,14 +230,22 @@ async def _load_all() -> list: f"tool(s) from {name}" ) except asyncio.TimeoutError: + # This source WAS configured (its token/URL is set) but is + # unreachable — a real, silent degradation (the agent runs + # without its tools). Warn loudly on stderr so it's not confused + # with a source the operator intentionally left disabled. On + # Railway a cold-start race can cause this; redeploy once the + # sidecar is up (see README "Cold-start note"). print( - f"[TOOLS] internal_source_tools: {name} MCP timed out " - f"after 8s, skipping" + f"[TOOLS] WARNING: {name} is configured but its MCP server " + f"timed out after 8s — running WITHOUT {name} tools", + file=sys.stderr, ) except Exception as e: print( - f"[TOOLS] internal_source_tools: {name} MCP unavailable, " - f"skipping ({e})" + f"[TOOLS] WARNING: {name} is configured but its MCP server " + f"is unavailable — running WITHOUT {name} tools ({e})", + file=sys.stderr, ) return loaded @@ -245,5 +254,8 @@ async def _load_all() -> list: except Exception as e: # Belt-and-suspenders: even a failure in the event-loop plumbing # itself (not just an individual server) must not break startup. - print(f"[TOOLS] internal_source_tools: failed to load MCP tools ({e})") + print( + f"[TOOLS] WARNING: failed to load internal-source MCP tools ({e})", + file=sys.stderr, + ) return [] diff --git a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md index fa2157b..5eab32b 100644 --- a/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md +++ b/docs/superpowers/specs/2026-07-16-kitebot-railway-oneclick-design.md @@ -20,7 +20,8 @@ deploy/button steps. path is absolute from repo root. - **Infrastructure-as-Code** (`.railway/railway.ts`, `railway/iac` SDK) defines the **whole project — every service + its build/start/health + env + inter-service wiring — in one - file**, applied with `railway config apply` (CLI, needs `railway login` + `railway link`). + file**, applied with `railway config apply` (CLI, needs `railway login` + `railway init` to + create a project, or `railway link` to select an existing one). - **Reference variables** wire services: a service reads another's private domain via `svc.env.RAILWAY_PRIVATE_DOMAIN` (compiles to `${{svc.RAILWAY_PRIVATE_DOMAIN}}`); Railway resolves `${{other.PORT}}`-style refs at deploy. @@ -80,7 +81,8 @@ declares/preserves them, never contains values):** managed by both IaC and config-as-code, so the agent's build+deploy config lives only in `.railway/railway.ts`. - **`README.md` "Deploy to Railway"** — two documented paths: (a) `railway login && railway - link && railway config apply` (IaC), and (b) publish a Railway template from the repo → get + init && railway config apply` (IaC; `railway link` instead of `init` for an existing project), + and (b) publish a Railway template from the repo → get a "Deploy on Railway" button (steps + the button markdown, noting it needs the deployer's Railway account). Plus the full **secrets checklist** grouped by service. - **Dependency changes in `package.json`** — add the `railway` devDependency (so diff --git a/scripts/start-notion-mcp.ts b/scripts/start-notion-mcp.ts index f8d9994..eced358 100644 --- a/scripts/start-notion-mcp.ts +++ b/scripts/start-notion-mcp.ts @@ -37,9 +37,10 @@ if (!notionToken) { // Port the sidecar listens on. Must agree with NOTION_MCP_URL as dialed by // runtime.ts (TS backend) AND by the Python agent/ backend on Railway — both // default to http://127.0.0.1:3001/mcp. Validated up front because it's passed -// as a `--port` arg to a `shell: true` spawn below — an unvalidated value with -// spaces/shell metacharacters could mangle or inject the command. An empty -// string (a bare `NOTION_MCP_PORT=` in .env) is treated as unset → default. +// as a `--port` arg to the spawn below, which uses a shell on Windows +// (`shell: isWindows`) — an unvalidated value with spaces/shell metacharacters +// could mangle or inject the command there. An empty string (a bare +// `NOTION_MCP_PORT=` in .env) is treated as unset → default. const rawPort = process.env["NOTION_MCP_PORT"] || undefined; if (rawPort !== undefined && !/^\d+$/.test(rawPort)) { console.error( @@ -61,7 +62,8 @@ const port = String(portNumber); // NOTION_MCP_HOST=:: so the sidecar listens on all interfaces and is reachable // over Railway's (IPv6) private network — without it the server binds 127.0.0.1 // and the agent's cross-container connection is refused. Validated because it's -// passed to a `shell: true` spawn below. +// passed as a `--host` arg to the spawn below (which uses a shell on Windows, +// `shell: isWindows`), and to prevent a leading `-` being read as a flag. const rawHost = process.env["NOTION_MCP_HOST"] || undefined; // Must start with an alphanumeric or ":" (for IPv6 like "::") — this both keeps // it shell-inert and prevents a leading-"-" value being consumed as a flag by From 332e730b88bf2faf81d59f6b9093829989c660fa Mon Sep 17 00:00:00 2001 From: GeneralJerel <85066839+GeneralJerel@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:53:11 -0700 Subject: [PATCH 12/12] docs(cr): README cold-start wording (logs a WARNING, not silent) + plan channel restart-policy back-port Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++++---- .../plans/2026-07-16-kitebot-railway-oneclick.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 863fb72..a817c8d 100644 --- a/README.md +++ b/README.md @@ -209,10 +209,10 @@ flips *Waiting for runtime → live*. > **Cold-start note (Notion):** the `agent` loads its Notion tools once, at startup. On a first > cold deploy the `agent` can finish booting before `notion-mcp` is accepting connections, in -> which case Notion research is silently unavailable for that deployment (chat, UI, and Tavily -> web research still work). If KiteBot can't reach Notion after the first deploy, **redeploy the -> `agent` service** once `notion-mcp` is up. (A lazy/retrying MCP load would remove this race — -> tracked as a follow-up.) +> which case Notion research is unavailable for that deployment — the agent logs a `WARNING` on +> its stderr (chat, UI, and Tavily web research still work). If KiteBot can't reach Notion after +> the first deploy, **redeploy the `agent` service** once `notion-mcp` is up. (A lazy/retrying +> MCP load would remove this race — tracked as a follow-up.) **"Deploy on Railway" button (optional):** to get a literal one-click button, publish this repo as a [Railway template](https://docs.railway.com/templates/create) from your Railway account, diff --git a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md index e3e4dfa..5998059 100644 --- a/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md +++ b/docs/superpowers/plans/2026-07-16-kitebot-railway-oneclick.md @@ -102,6 +102,7 @@ export default defineRailway(() => { const channel = service("channel", { source: github(REPO), start: "pnpm channel", + deploy: { restartPolicyType: "ON_FAILURE", restartPolicyMaxRetries: 5 }, env: { AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", // INTELLIGENCE_CHANNEL_NAME left unmanaged (app/managed.ts defaults it to