Complete guide for deploying Hoot with Cloudflare Pages (frontend) + Workers (server).
┌─────────────────────────────────────────────────────┐
│ Cloudflare Pages │
│ https://hoot.pages.dev │
│ ├── React Frontend (static) │
│ └── Connects to Workers via VITE_BACKEND_URL │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Cloudflare Workers │
│ https://hoot-server.your-subdomain.workers.dev │
│ ├── server/server-worker.js │
│ ├── Durable Objects (user data, favicon cache) │
│ └── JWT authentication │
└─────────────────────────────────────────────────────┘
npm install -g wrangler@latest
wrangler loginnode -e "
const { generateKeyPair, exportJWK } = require('jose');
const { randomUUID } = require('crypto');
const fs = require('fs');
(async () => {
const { publicKey, privateKey } = await generateKeyPair('RS256');
const publicJwk = await exportJWK(publicKey);
publicJwk.use = 'sig';
publicJwk.alg = 'RS256';
publicJwk.kid = randomUUID();
const privateJwk = await exportJWK(privateKey);
privateJwk.kid = publicJwk.kid;
fs.writeFileSync('jwks.json', JSON.stringify({ keys: [publicJwk] }, null, 2));
fs.writeFileSync('private-key.json', JSON.stringify(privateJwk, null, 2));
console.log('✅ Keys generated: jwks.json, private-key.json');
})();
"# Set JWT private key (paste contents of private-key.json)
wrangler secret put JWT_PRIVATE_KEY
# Set JWKS (paste contents of jwks.json)
wrangler secret put JWT_JWKS
# Set Portkey credentials
wrangler secret put PORTKEY_ORG_ID
wrangler secret put PORTKEY_WORKSPACE_SLUG
# Optional: Set Cloudflare API Token (only needed for wrangler dev with remote = true)
# For production deployments, the Workers AI binding handles auth automatically
# wrangler secret put CLOUDFLARE_API_TOKENImportant: Set your Cloudflare Account ID for Workers AI support:
Find your Account ID:
- Cloudflare Dashboard → Overview → Account ID (right sidebar)
- Or run:
wrangler whoami
npm run deploy:cloudflareNote the Workers URL (e.g., https://hoot-server.your-subdomain.workers.dev)
The build script is pre-configured to use the production backend URL (https://hoot-server-production.portkey-ai.workers.dev).
To use a custom backend URL, you can either:
Option A: Override with environment variable
VITE_BACKEND_URL=https://hoot-server.your-subdomain.workers.dev npm run buildOption B: Create .env.production file (optional)
# Create .env.production file to permanently override
cat > .env.production << EOF
VITE_BACKEND_URL=https://hoot-server.your-subdomain.workers.dev
EOFOption C: Use default (Portkey production)
npm run buildThis creates a dist/ folder with static files.
npx wrangler pages deploy dist --project-name=hoot- Go to Cloudflare Dashboard
- Navigate to Pages → Create a project
- Choose "Direct Upload"
- Upload the
dist/folder - Set Build command:
npm run build - Set Build output directory:
dist - Add environment variable:
- Key:
VITE_BACKEND_URL - Value:
https://hoot-server.your-subdomain.workers.dev
- Key:
- Connect your GitHub repository
- Set build settings:
- Build command:
npm run build - Build output directory:
dist - Environment variables:
VITE_BACKEND_URL=https://hoot-server.your-subdomain.workers.dev
- Build command:
- Cloudflare will auto-deploy on every push!
Update wrangler.jsonc to allow your Pages domain:
{
"vars": {
"FRONTEND_URL": "https://hoot.pages.dev"
}
}Or set it as a secret:
wrangler secret put FRONTEND_URL
# Paste: https://hoot.pages.devRedeploy workers:
npm run deploy:cloudflarecurl https://hoot-server.your-subdomain.workers.dev/healthExpected:
{
"status": "ok",
"message": "MCP Backend Server is running (Cloudflare Workers)",
"activeConnections": 0
}Visit https://hoot.pages.dev and:
- Open DevTools → Network tab
- Try connecting to a server
- Verify requests go to your Workers URL
VITE_BACKEND_URL=https://hoot-server.your-subdomain.workers.dev{
"vars": {
"FRONTEND_URL": "https://hoot.pages.dev"
}
// Secrets (set via wrangler secret put):
// - JWT_PRIVATE_KEY
// - JWT_JWKS
// - PORTKEY_ORG_ID
// - PORTKEY_WORKSPACE_SLUG
}{
"routes": [
{
"pattern": "api.yourdomain.com/*",
"zone_name": "yourdomain.com"
}
]
}Then update frontend env:
VITE_BACKEND_URL=https://api.yourdomain.comIn Cloudflare Dashboard:
- Pages → Your Project → Custom domains
- Add
hoot.yourdomain.com - Cloudflare will auto-configure DNS
Error: Cannot connect to Hoot backend
Fix: Check CORS - ensure FRONTEND_URL in Workers matches your Pages URL
Error: JWT keys not initialized
Fix: Verify secrets are set:
wrangler secret listShould show:
JWT_PRIVATE_KEYJWT_JWKSPORTKEY_ORG_IDPORTKEY_WORKSPACE_SLUG
Error: Could not resolve "crypto"
Fix: Ensure wrangler.jsonc has:
{
"compatibility_flags": ["nodejs_compat"]
}Error: Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler.
Explanation: Cloudflare Workers are stateless - each request runs in an isolated context. MCP connections cannot be reused across requests.
Current Solution: Hoot creates a new MCP client for each request. This means:
- ✅ Works reliably in Workers
⚠️ Slightly slower (reconnects each time)- ✅ OAuth tokens are cached in Durable Objects
Future Enhancement: Move MCP client management to Durable Objects for persistent connections.
Good news! Hoot now supports semantic tool filtering on Cloudflare using Workers AI with OpenAI-compatible endpoints!
How it works:
- Automatically detects if Workers AI is available (
env.AIbinding) - Uses the OpenAI provider from
@portkey-ai/mcp-tool-filterwith Workers AI's OpenAI-compatible endpoint - Provides fast, edge-based semantic filtering with no custom code needed
Benefits:
- ✅ Semantic filtering works on Cloudflare (no more falling back to all tools)
- ⚡ Fast & low-latency (runs on Cloudflare's edge network)
- 💰 Cost-effective (~$0.011 per 1,000 filtering requests)
- 🌍 Multiple model options (including multilingual support)
- 🔌 Uses standard OpenAI-compatible API (no custom provider needed)
Available Models:
@cf/baai/bge-base-en-v1.5(default) - Good balance, 768-dim@cf/baai/bge-small-en-v1.5- Faster, 384-dim@cf/baai/bge-large-en-v1.5- More accurate, 1024-dim@cf/google/embeddinggemma-300m- Multilingual (100+ languages)@cf/baai/bge-m3- Multi-functional, multi-lingual, multi-granularity
Setup:
- Add Workers AI binding in
wrangler.jsonc(already included):
{
"ai": {
"binding": "AI"
}
}- Set your Cloudflare Account ID:
{
"vars": {
"CLOUDFLARE_ACCOUNT_ID": "your-account-id-here"
}
}- Set Cloudflare API Token (secret):
wrangler secret put CLOUDFLARE_API_TOKEN
# Paste your API token (from Cloudflare Dashboard → API Tokens)- Optional: Choose a different embedding model:
{
"vars": {
"WORKERS_AI_EMBEDDING_MODEL": "@cf/google/embeddinggemma-300m"
}
}How it uses OpenAI-compatible API: Workers AI provides OpenAI-compatible endpoints at:
https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1
Hoot's tool filter uses the existing OpenAI provider with this base URL, making it work seamlessly with Workers AI models!
Fallback behavior:
- If Workers AI is not configured, falls back to Node.js behavior (Transformers.js on Node, all tools on Workers)
- The feature gracefully degrades, ensuring Hoot works in all environments
Good news: Workers AI works with wrangler dev for local development!
Setup:
- Enable remote mode in
wrangler.jsonc(already configured):
{
"ai": {
"binding": "AI",
"remote": true // Connects to Cloudflare's Workers AI service
}
}- Add your credentials to
.dev.vars(create this file in project root):
# .dev.vars (for local development only - never commit this!)
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_API_TOKEN=your-api-token- Start local development:
npm run server:worker # or: wrangler devImportant notes:
⚠️ Not truly local:wrangler devmakes real API calls to Cloudflare's Workers AI service- 💰 May incur costs: Usage during development counts toward your Workers AI usage
- ✅ Fast testing: Great for testing semantic filtering without deploying
- 🔒 Keep .dev.vars private: Add it to
.gitignore(already included)
Alternative for local development: If you don't want to use Workers AI credits during development, run the Node.js server instead:
npm run server # Uses local Transformers.js embeddings (free)The Node.js server uses free local embeddings with Transformers.js, perfect for development!
Note: This section describes the old behavior before Workers AI support was added. With Workers AI enabled (default), semantic filtering now works on Cloudflare!
Issue (without Workers AI): Semantic tool filtering does not work on Cloudflare Workers without Workers AI.
Explanation: The @portkey-ai/mcp-tool-filter package uses Transformers.js and ONNX Runtime for local embeddings. These dependencies require Node.js APIs that are not available in Cloudflare Workers' V8 isolate environment.
Old Behavior (if Workers AI disabled):
⚠️ The feature is automatically disabled on Workers⚠️ All available tools are sent to the LLM (limited to first 120 if more than 128)- ✅ You can still use the
@mention feature to manually pin specific tools/servers
Alternative (if you don't want to use Workers AI):
- Use mention feature: Type
@in the chat to manually pin specific servers or tools - Deploy to Node.js: For Transformers.js filtering, deploy the backend using
npm run server - Hybrid setup: Run frontend on Cloudflare Pages, backend on a Node.js server
To disable Workers AI (not recommended):
Remove the ai binding from wrangler.jsonc and semantic filtering will fall back to the old behavior.
- Workers: 100,000 requests/day (free)
- Durable Objects: 1GB storage + 1M requests/month (free)
- Pages: Unlimited static hosting (free)
Total: $0/month for light usage
- Workers: $5/month (10M requests)
- Durable Objects: $0.15/million requests + storage
- Pages: Free
Estimate: ~$10-30/month for 10,000 users
name: Deploy to Cloudflare
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: npm ci
- name: Build frontend
run: npm run build
env:
VITE_BACKEND_URL: ${{ secrets.VITE_BACKEND_URL }}
- name: Deploy Workers
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Deploy Pages
run: npx wrangler pages deploy dist --project-name=hoot
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Add these secrets to your GitHub repo:
CLOUDFLARE_API_TOKENVITE_BACKEND_URL
You're now running Hoot globally on Cloudflare's edge! 🌍
{ "name": "hoot-server", "main": "server/server-worker.js", "compatibility_date": "2024-11-04", "compatibility_flags": ["nodejs_compat"], // Workers AI binding for semantic tool filtering "ai": { "binding": "AI" }, "durable_objects": { "bindings": [ { "name": "USER_DATA", "class_name": "UserDataDO" }, { "name": "FAVICON_CACHE", "class_name": "FaviconCacheDO" } ] }, "migrations": [ { "tag": "v1", "new_classes": ["UserDataDO", "FaviconCacheDO"] } ], "vars": { "FRONTEND_URL": "https://hoot.pages.dev", // Update with your Pages URL "CLOUDFLARE_ACCOUNT_ID": "your-account-id-here" // REQUIRED: Replace with your account ID }, "env": { "production": { "name": "hoot-server-production", "vars": { "FRONTEND_URL": "https://hoot.yourdomain.com", "CLOUDFLARE_ACCOUNT_ID": "your-account-id-here" // REQUIRED: Replace with your account ID }, "ai": { "binding": "AI" } } } }