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
104 changes: 104 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ This folder contains examples for configuring agent-relay in different environme
| `.env.example` | Environment variables for dotenv configuration |
| `cli-usage.sh` | CLI command examples and options |
| `programmatic-usage.ts` | Using agent-relay as a Node.js library |
| `slack-claude-bot.ts` | Slack bot with Claude Code via agent-relay |
| `slack-claude-standalone.ts` | Standalone Slack + Claude Code bot (no relay) |
| `discord-claude-bot.ts` | Discord bot with Claude Code via agent-relay |
| `discord-claude-standalone.ts` | Standalone Discord + Claude Code bot (no relay) |
| `slack-codex-standalone.ts` | Standalone Slack + Codex CLI bot |
| `discord-codex-standalone.ts` | Standalone Discord + Codex CLI bot |
| `docker-compose.yml` | Docker Compose setup for containerized deployment |
| `agent-relay.service` | Systemd service file for Linux servers |
| `team-config.json` | Team configuration with multiple agents |
Expand Down Expand Up @@ -54,6 +60,104 @@ const daemon = new Daemon({
});
```

## Slack Bot Examples

Two Slack bot examples are included - both use Claude Code CLI (your subscription, no API costs).

### Standalone Bot (Quick Test)

No agent-relay needed - just Slack + Claude Code:

```bash
# Install Slack SDK
npm install @slack/bolt

# Run (ensure `claude` CLI is logged in)
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... npx ts-node examples/slack-claude-standalone.ts
```

### Agent-Relay Bridge

Bridges Slack with your relay network - agents can send messages to Slack:

```bash
# Start relay daemon first
agent-relay up

# Run the bridge
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... npx ts-node examples/slack-claude-bot.ts
```

### Slack App Setup

1. Create app at https://api.slack.com/apps
2. Enable **Socket Mode** → copy App Token (`xapp-...`)
3. **OAuth & Permissions** → add scopes: `app_mentions:read`, `chat:write`
4. **Event Subscriptions** → subscribe to `app_mention`
5. Install to workspace → copy Bot Token (`xoxb-...`)

## Discord Bot Examples

Two Discord bot examples are included - both use Claude Code CLI (your subscription, no API costs).

### Standalone Bot (Quick Test)

No agent-relay needed - just Discord + Claude Code:

```bash
# Install Discord.js
npm install discord.js

# Run (ensure `claude` CLI is logged in)
DISCORD_TOKEN=... npx ts-node examples/discord-claude-standalone.ts
```

### Agent-Relay Bridge

Bridges Discord with your relay network - agents can send messages to Discord:

```bash
# Start relay daemon first
agent-relay up

# Run the bridge
DISCORD_TOKEN=... npx ts-node examples/discord-claude-bot.ts
```

### Discord App Setup

1. Create app at https://discord.com/developers/applications
2. **Bot** → Add Bot → copy Token
3. **Bot** → enable **Message Content Intent**
4. **OAuth2** → URL Generator → select `bot` scope
5. Select permissions: `Send Messages`, `Read Message History`
6. Use generated URL to invite bot to your server

## Codex Bot Examples

Codex CLI examples for both Slack and Discord (uses OpenAI Codex subscription).

### Setup Codex CLI

```bash
npm install -g @openai/codex
codex auth login
```

### Slack + Codex

```bash
npm install @slack/bolt
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... npx ts-node examples/slack-codex-standalone.ts
```

### Discord + Codex

```bash
npm install discord.js
DISCORD_TOKEN=... npx ts-node examples/discord-codex-standalone.ts
```

## Configuration Priority

1. CLI flags (highest priority)
Expand Down
244 changes: 244 additions & 0 deletions examples/discord-claude-bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
/**
* Discord Claude Bot via Agent Relay
*
* A Discord bot that uses Claude Code CLI (subscription-based, no API costs)
* bridged through agent-relay for message coordination.
*
* Setup:
* 1. Create Discord app: https://discord.com/developers/applications
* 2. Bot → Add Bot → copy Token
* 3. Bot → enable "Message Content Intent"
* 4. OAuth2 → URL Generator → "bot" scope + "Send Messages" + "Read Message History"
* 5. Use generated URL to invite bot to your server
* 6. Ensure `claude` CLI is installed and logged in
* 7. Start agent-relay daemon: `agent-relay up`
*
* Run:
* DISCORD_TOKEN=... npx ts-node examples/discord-claude-bot.ts
*/

import { Client, GatewayIntentBits, Message, TextChannel } from 'discord.js';
import { spawn } from 'child_process';
import { RelayClient } from 'agent-relay';
import { getProjectPaths } from 'agent-relay';

// Configuration
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
const BOT_NAME = process.env.BOT_NAME || 'DiscordBot';
const DEFAULT_CHANNEL_ID = process.env.DISCORD_DEFAULT_CHANNEL;

if (!DISCORD_TOKEN) {
console.error('Missing DISCORD_TOKEN');
process.exit(1);
}

// Initialize Discord client
const discord = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});

// Initialize agent-relay client
const paths = getProjectPaths();
const relay = new RelayClient({
name: BOT_NAME,
socketPath: paths.socketPath,
});

/**
* Ask Claude using the CLI (uses subscription, not API)
*/
async function askClaude(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const claude = spawn('claude', ['--print', prompt], {
env: { ...process.env },
stdio: ['pipe', 'pipe', 'pipe'],
});

let output = '';
let error = '';

claude.stdout.on('data', (data) => {
output += data.toString();
});

claude.stderr.on('data', (data) => {
error += data.toString();
});

claude.on('close', (code) => {
if (code === 0) {
resolve(output.trim());
} else {
reject(new Error(error || `Claude exited with code ${code}`));
}
});

const timeout = setTimeout(() => {
claude.kill();
reject(new Error('Claude response timeout'));
}, 120000);

claude.on('close', () => clearTimeout(timeout));
});
}

// Split long messages for Discord's 2000 char limit
function splitMessage(text: string, maxLength = 1900): string[] {
const chunks: string[] = [];
let remaining = text;

while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}

let splitAt = remaining.lastIndexOf('\n', maxLength);
if (splitAt === -1 || splitAt < maxLength / 2) {
splitAt = remaining.lastIndexOf(' ', maxLength);
}
if (splitAt === -1 || splitAt < maxLength / 2) {
splitAt = maxLength;
}

chunks.push(remaining.slice(0, splitAt));
remaining = remaining.slice(splitAt).trimStart();
}

return chunks;
}

/**
* Handle Discord mentions
*/
discord.on('messageCreate', async (message: Message) => {
if (message.author.bot) return;

const isMentioned = message.mentions.has(discord.user!);
const isDM = !message.guild;

if (!isMentioned && !isDM) return;

const text = message.content.replace(/<@!?\d+>/g, '').trim();
if (!text) return;

console.log(`[Discord] ${message.author.tag}: ${text}`);

try {
// Notify relay that we received a Discord message
await relay.send({
to: '*',
body: `[Discord #${(message.channel as TextChannel).name || 'DM'}] ${message.author.tag}: ${text}`,
data: {
source: 'discord',
channelId: message.channel.id,
guildId: message.guild?.id,
userId: message.author.id,
},
});

// Show typing
await message.channel.sendTyping();

// Get response from Claude
const response = await askClaude(text);

// Send response to Discord
const chunks = splitMessage(response);
for (const chunk of chunks) {
await message.reply({ content: chunk, allowedMentions: { repliedUser: false } });
}

// Notify relay of the response
await relay.send({
to: '*',
body: `[Discord Response] ${response.substring(0, 200)}...`,
data: { source: 'discord-response', channelId: message.channel.id },
});
} catch (err) {
console.error('[Discord] Error:', err);
await message.reply(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
});

/**
* Handle incoming relay messages - forward to Discord
*/
relay.on('message', async (msg) => {
// Skip messages from ourselves or other Discord sources
if (msg.from === BOT_NAME || msg.data?.source?.startsWith('discord')) {
return;
}

console.log(`[Relay] Message from ${msg.from}: ${msg.body}`);

// Check if message specifies a Discord channel
const targetChannelId = msg.data?.discordChannel || DEFAULT_CHANNEL_ID;

if (targetChannelId) {
try {
const channel = await discord.channels.fetch(targetChannelId);
if (channel?.isTextBased()) {
const chunks = splitMessage(`**${msg.from}**: ${msg.body}`);
for (const chunk of chunks) {
await (channel as TextChannel).send(chunk);
}
}
} catch (err) {
console.error('[Relay→Discord] Failed to post:', err);
}
}
});

/**
* Handle relay connection events
*/
relay.on('connected', () => {
console.log(`[Relay] Connected as ${BOT_NAME}`);
});

relay.on('disconnected', () => {
console.log('[Relay] Disconnected, will reconnect...');
});

discord.on('ready', () => {
console.log(`[Discord] Logged in as ${discord.user?.tag}`);
});

/**
* Startup
*/
async function main() {
try {
// Connect to relay daemon
await relay.connect();
console.log(`[Relay] Connected to ${paths.socketPath}`);

// Login to Discord
await discord.login(DISCORD_TOKEN);

// Announce presence
await relay.broadcast(`${BOT_NAME} online - bridging Discord ↔ Relay`);

console.log('\nReady! Mention the bot in Discord to interact.');
console.log('Messages from relay agents will be forwarded to Discord.\n');
} catch (err) {
console.error('Startup failed:', err);
process.exit(1);
}
}

// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down...');
await relay.disconnect();
discord.destroy();
process.exit(0);
});

main();
Loading
Loading