Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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").
//
// 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. 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(),
},
});

// 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" }),
build: { builder: "NIXPACKS" },
deploy: {
// 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,
restartPolicyType: "ON_FAILURE",
restartPolicyMaxRetries: 5,
},
env: {
// Pin PORT so uvicorn's bind and ${{agent.PORT}} (dialed by channel) agree.
PORT: "8123",
// internal research source (optional Notion), wired to the sidecar on its
// 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/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.
//
// secrets (set in Railway UI): OPENAI_API_KEY required; others optional:
OPENAI_API_KEY: preserve(),
TAVILY_API_KEY: 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",
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.
AGENT_URL: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/",
// 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(),
INTELLIGENCE_ORG_ID: preserve(),
INTELLIGENCE_PROJECT_ID: preserve(),
INTELLIGENCE_CHANNEL_ID: preserve(),
},
});

return project("kitebot", { resources: [notionMcp, agent, channel] });
});
76 changes: 73 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -70,7 +70,11 @@ 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)

# Optional integrations (see setup.md):
LINEAR_API_KEY=lin_api_... # optional — lets the agent file Linear tickets

# Self-hosted mode:
SLACK_BOT_TOKEN=xoxb-...
Expand Down Expand Up @@ -137,7 +141,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`:
Expand All @@ -152,6 +156,72 @@ 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
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 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
`preserve()` and never stores values — you fill them in:

- **`agent`** — `OPENAI_API_KEY` (required); `TAVILY_API_KEY` (optional — enables web research);
`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).

The inter-service URLs/ports are wired for you in [`.railway/railway.ts`](./.railway/railway.ts).
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
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 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,
then drop the generated button in:

```md
[![Deploy on Railway](https://railway.com/button.svg)](https://railway.com/new/template/<your-template-id>)
```

## Don't want to host it yourself?

Self-hosting means you run and scale the runtime, persistence, and inspection tooling yourself.
Expand Down
38 changes: 30 additions & 8 deletions agent/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,23 @@
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.
# 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=["*"],
allow_origins=_cors_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
Expand Down Expand Up @@ -66,24 +78,34 @@ 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


def main():
"""Run the server with uvicorn"""
import uvicorn

host = os.getenv("SERVER_HOST", "0.0.0.0")
# 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")
# 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") 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") 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")
Expand Down
22 changes: 17 additions & 5 deletions agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import os
import sys
import asyncio
from typing import Any
from concurrent.futures import ThreadPoolExecutor
Expand Down Expand Up @@ -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

Expand All @@ -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 []
Loading