Initial commit: Discord-Claude Gateway with event-driven agent runtime

This commit is contained in:
2026-02-22 00:42:56 -05:00
parent 77d7c74909
commit bbcc9014f5
5 changed files with 120 additions and 136 deletions

View File

@@ -19,8 +19,8 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks
## Prerequisites ## Prerequisites
- **Node.js** 18+ - **Node.js** 18+
- **Claude Code CLI** — [Install Claude Code](https://docs.anthropic.com/en/docs/claude-code/getting-started) and sign in with your subscription
- **Discord Bot Token** — [Create a bot](https://discord.com/developers/applications) with Message Content Intent enabled - **Discord Bot Token** — [Create a bot](https://discord.com/developers/applications) with Message Content Intent enabled
- **Anthropic API Key** — Get one from [console.anthropic.com](https://console.anthropic.com/) (this is separate from a Claude Code CLI subscription)
## Quick Start ## Quick Start
@@ -28,9 +28,11 @@ All inputs — Discord messages, heartbeat timers, cron jobs, lifecycle hooks
# Install dependencies # Install dependencies
npm install npm install
# Make sure Claude Code CLI is installed and you're signed in
claude --version
# Set required environment variables # Set required environment variables
export DISCORD_BOT_TOKEN=your-discord-bot-token export DISCORD_BOT_TOKEN=your-discord-bot-token
export ANTHROPIC_API_KEY=your-anthropic-api-key
# Create config directory with persona files # Create config directory with persona files
mkdir config mkdir config
@@ -65,11 +67,11 @@ All settings are via environment variables:
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| |----------|----------|---------|-------------|
| `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token | | `DISCORD_BOT_TOKEN` | Yes | — | Discord bot token |
| `ANTHROPIC_API_KEY` | Yes | — | Anthropic API key from [console.anthropic.com](https://console.anthropic.com/) | | `CLAUDE_CLI_PATH` | No | `claude` | Path to the Claude Code CLI binary |
| `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Agent SDK tools | | `ALLOWED_TOOLS` | No | `Read,Write,Edit,Glob,Grep,WebSearch,WebFetch` | Comma-separated Claude Code tools |
| `PERMISSION_MODE` | No | `bypassPermissions` | Agent SDK permission mode | | `PERMISSION_MODE` | No | `bypassPermissions` | Claude Code permission mode |
| `QUERY_TIMEOUT_MS` | No | `120000` | Query timeout in milliseconds | | `QUERY_TIMEOUT_MS` | No | `120000` | Query timeout in milliseconds |
| `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous Agent SDK queries | | `MAX_CONCURRENT_QUERIES` | No | `5` | Max simultaneous Claude queries |
| `CONFIG_DIR` | No | `./config` | Path to markdown config directory | | `CONFIG_DIR` | No | `./config` | Path to markdown config directory |
| `MAX_QUEUE_DEPTH` | No | `100` | Max events in the queue | | `MAX_QUEUE_DEPTH` | No | `100` | Max events in the queue |
| `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron output | | `OUTPUT_CHANNEL_ID` | No | — | Discord channel for heartbeat/cron output |
@@ -196,14 +198,14 @@ npm run build
npm start npm start
``` ```
## API Key vs Claude Code Subscription ## Claude Code CLI vs API Key
The Claude Agent SDK requires an **Anthropic API key** (`ANTHROPIC_API_KEY`), which is separate from a Claude Code CLI subscription. Get one at [console.anthropic.com](https://console.anthropic.com/). API usage is billed per-token. This gateway uses the **Claude Code CLI** (`claude -p`) instead of the Anthropic API directly. This means:
The SDK also supports: - You use your existing **Claude Code subscription** — no separate API key needed
- **Amazon Bedrock**: set `CLAUDE_CODE_USE_BEDROCK=1` + AWS credentials - Just sign in with `claude` in your terminal and you're good to go
- **Google Vertex AI**: set `CLAUDE_CODE_USE_VERTEX=1` + GCP credentials - The gateway shells out to `claude -p "prompt" --output-format json` for each query
- **Azure AI Foundry**: set `CLAUDE_CODE_USE_FOUNDRY=1` + Azure credentials - Set `CLAUDE_CLI_PATH` if `claude` isn't in your PATH
## License ## License

View File

@@ -13,7 +13,6 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"node-cron": "^4.2.1" "node-cron": "^4.2.1"
}, },

View File

@@ -1,4 +1,4 @@
import { query } from "@anthropic-ai/claude-agent-sdk"; import { execFile } from "node:child_process";
import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js"; import type { Event, MessagePayload, HeartbeatPayload, CronPayload, HookPayload } from "./event-queue.js";
import type { MarkdownConfigLoader } from "./markdown-config-loader.js"; import type { MarkdownConfigLoader } from "./markdown-config-loader.js";
import type { SystemPromptAssembler } from "./system-prompt-assembler.js"; import type { SystemPromptAssembler } from "./system-prompt-assembler.js";
@@ -14,6 +14,18 @@ export interface EventResult {
error?: string; error?: string;
} }
interface ClaudeJsonResponse {
type: string;
subtype?: string;
session_id?: string;
result?: string;
is_error?: boolean;
duration_ms?: number;
duration_api_ms?: number;
num_turns?: number;
cost_usd?: number;
}
export class AgentRuntime { export class AgentRuntime {
private config: GatewayConfig; private config: GatewayConfig;
private sessionManager: SessionManager; private sessionManager: SessionManager;
@@ -36,25 +48,19 @@ export class AgentRuntime {
} }
async processEvent(event: Event): Promise<EventResult> { async processEvent(event: Event): Promise<EventResult> {
// Fire agent_begin inline hook
await this.hookManager.fireInline("agent_begin", this); await this.hookManager.fireInline("agent_begin", this);
try { try {
const result = await this.processEventCore(event); const result = await this.processEventCore(event);
// Fire agent_stop inline hook
await this.hookManager.fireInline("agent_stop", this); await this.hookManager.fireInline("agent_stop", this);
return result; return result;
} catch (error) { } catch (error) {
// Fire agent_stop even on error
await this.hookManager.fireInline("agent_stop", this); await this.hookManager.fireInline("agent_stop", this);
throw error; throw error;
} }
} }
private async processEventCore(event: Event): Promise<EventResult> { private async processEventCore(event: Event): Promise<EventResult> {
// Read all markdown configs fresh
const configs = await this.markdownConfigLoader.loadAll(this.config.configDir); const configs = await this.markdownConfigLoader.loadAll(this.config.configDir);
const systemPrompt = this.systemPromptAssembler.assemble(configs); const systemPrompt = this.systemPromptAssembler.assemble(configs);
@@ -79,147 +85,128 @@ export class AgentRuntime {
const existingSessionId = this.sessionManager.getSessionId(channelId); const existingSessionId = this.sessionManager.getSessionId(channelId);
try { try {
const responseText = await this.executeQuery( const response = await this.executeClaude(promptText, systemPrompt, existingSessionId);
promptText,
systemPrompt, if (response.session_id && channelId) {
channelId, this.sessionManager.setSessionId(channelId, response.session_id);
existingSessionId, }
);
return { return {
responseText, responseText: response.result,
targetChannelId: channelId, targetChannelId: channelId,
sessionId: this.sessionManager.getSessionId(channelId), sessionId: response.session_id,
}; };
} catch (error) { } catch (error) {
// If session is corrupted, remove the binding
if (this.isSessionCorrupted(error)) { if (this.isSessionCorrupted(error)) {
this.sessionManager.removeSession(channelId); this.sessionManager.removeSession(channelId);
} }
return { error: formatErrorForUser(error), targetChannelId: channelId };
const errorMessage = formatErrorForUser(error);
return {
error: errorMessage,
targetChannelId: channelId,
};
} }
} }
private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> { private async processHeartbeat(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as HeartbeatPayload; const payload = event.payload as HeartbeatPayload;
const targetChannelId = this.config.outputChannelId;
try { try {
const responseText = await this.executeQuery( const response = await this.executeClaude(payload.instruction, systemPrompt);
payload.instruction, return { responseText: response.result, targetChannelId: this.config.outputChannelId };
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) { } catch (error) {
const errorMessage = formatErrorForUser(error); return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
return { error: errorMessage, targetChannelId };
} }
} }
private async processCron(event: Event, systemPrompt: string): Promise<EventResult> { private async processCron(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as CronPayload; const payload = event.payload as CronPayload;
const targetChannelId = this.config.outputChannelId;
try { try {
const responseText = await this.executeQuery( const response = await this.executeClaude(payload.instruction, systemPrompt);
payload.instruction, return { responseText: response.result, targetChannelId: this.config.outputChannelId };
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) { } catch (error) {
const errorMessage = formatErrorForUser(error); return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
return { error: errorMessage, targetChannelId };
} }
} }
private async processHook(event: Event, systemPrompt: string): Promise<EventResult> { private async processHook(event: Event, systemPrompt: string): Promise<EventResult> {
const payload = event.payload as HookPayload; const payload = event.payload as HookPayload;
if (!payload.instruction) return {};
// If no instruction, return empty result (no query needed)
if (!payload.instruction) {
return {};
}
const targetChannelId = this.config.outputChannelId;
try { try {
const responseText = await this.executeQuery( const response = await this.executeClaude(payload.instruction, systemPrompt);
payload.instruction, return { responseText: response.result, targetChannelId: this.config.outputChannelId };
systemPrompt,
);
return {
responseText,
targetChannelId,
};
} catch (error) { } catch (error) {
const errorMessage = formatErrorForUser(error); return { error: formatErrorForUser(error), targetChannelId: this.config.outputChannelId };
return { error: errorMessage, targetChannelId };
} }
} }
private async executeQuery( private executeClaude(
promptText: string, promptText: string,
systemPrompt: string, systemPrompt: string,
channelId?: string, sessionId?: string,
existingSessionId?: string, ): Promise<ClaudeJsonResponse> {
): Promise<string> { return new Promise((resolve, reject) => {
const options: Record<string, unknown> = { const args: string[] = [
allowedTools: this.config.allowedTools, "-p", promptText,
permissionMode: this.config.permissionMode, "--output-format", "json",
systemPrompt, "--system-prompt", systemPrompt,
}; "--dangerously-skip-permissions",
];
if (existingSessionId) { // Resume existing session
options.resume = existingSessionId; if (sessionId) {
args.push("--resume", sessionId);
} }
const queryPromise = this.consumeStream( // Allowed tools
query({ prompt: promptText, options }), if (this.config.allowedTools.length > 0) {
channelId, args.push("--allowedTools", ...this.config.allowedTools);
}
// Max turns to prevent runaway loops
args.push("--max-turns", "25");
const child = execFile(
this.config.claudeCliPath,
args,
{
timeout: this.config.queryTimeoutMs,
maxBuffer: 10 * 1024 * 1024, // 10MB
encoding: "utf-8",
},
(error, stdout, stderr) => {
if (error) {
if (error.killed || error.code === "ETIMEDOUT") {
reject(new Error("Query timed out"));
return;
}
reject(new Error(`Claude CLI error: ${stderr || error.message}`));
return;
}
try {
// The JSON output may contain multiple JSON objects (one per line for stream-json)
// With --output-format json, the last line is the final result
const lines = stdout.trim().split("\n");
const lastLine = lines[lines.length - 1];
const parsed = JSON.parse(lastLine) as ClaudeJsonResponse;
if (parsed.is_error) {
reject(new Error(`Claude error: ${parsed.result ?? "Unknown error"}`));
return;
}
resolve(parsed);
} catch (parseError) {
// If JSON parsing fails, treat stdout as plain text result
resolve({
type: "result",
result: stdout.trim(),
});
}
},
); );
const timeoutPromise = new Promise<never>((_, reject) => { // Handle child process errors
setTimeout(() => { child.on("error", (err) => {
reject(new Error("Query timed out")); reject(new Error(`Failed to spawn Claude CLI: ${err.message}`));
}, this.config.queryTimeoutMs); });
}); });
return Promise.race([queryPromise, timeoutPromise]);
}
private async consumeStream(
stream: AsyncIterable<any>,
channelId?: string,
): Promise<string> {
let resultText = "";
for await (const message of stream) {
// Store session_id from init messages
if (message.type === "system" && message.subtype === "init" && channelId) {
this.sessionManager.setSessionId(channelId, message.session_id);
}
// Collect result text
if ("result" in message && typeof message.result === "string") {
resultText += message.result;
}
}
return resultText;
} }
private isSessionCorrupted(error: unknown): boolean { private isSessionCorrupted(error: unknown): boolean {

View File

@@ -1,6 +1,6 @@
export interface GatewayConfig { export interface GatewayConfig {
discordBotToken: string; discordBotToken: string;
anthropicApiKey: string; claudeCliPath: string;
allowedTools: string[]; allowedTools: string[];
permissionMode: string; permissionMode: string;
queryTimeoutMs: number; queryTimeoutMs: number;
@@ -16,15 +16,14 @@ const DEFAULT_QUERY_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_CONCURRENT_QUERIES = 5; const DEFAULT_MAX_CONCURRENT_QUERIES = 5;
const DEFAULT_CONFIG_DIR = "./config"; const DEFAULT_CONFIG_DIR = "./config";
const DEFAULT_MAX_QUEUE_DEPTH = 100; const DEFAULT_MAX_QUEUE_DEPTH = 100;
const DEFAULT_CLAUDE_CLI_PATH = "claude";
export function loadConfig(): GatewayConfig { export function loadConfig(): GatewayConfig {
const missing: string[] = []; const missing: string[] = [];
const discordBotToken = process.env.DISCORD_BOT_TOKEN; const discordBotToken = process.env.DISCORD_BOT_TOKEN;
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN"); if (!discordBotToken) missing.push("DISCORD_BOT_TOKEN");
if (!anthropicApiKey) missing.push("ANTHROPIC_API_KEY");
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new Error(
@@ -32,6 +31,8 @@ export function loadConfig(): GatewayConfig {
); );
} }
const claudeCliPath = process.env.CLAUDE_CLI_PATH ?? DEFAULT_CLAUDE_CLI_PATH;
const allowedToolsRaw = process.env.ALLOWED_TOOLS; const allowedToolsRaw = process.env.ALLOWED_TOOLS;
const allowedTools = allowedToolsRaw const allowedTools = allowedToolsRaw
? allowedToolsRaw.split(",").map((t) => t.trim()) ? allowedToolsRaw.split(",").map((t) => t.trim())
@@ -57,7 +58,7 @@ export function loadConfig(): GatewayConfig {
return { return {
discordBotToken: discordBotToken!, discordBotToken: discordBotToken!,
anthropicApiKey: anthropicApiKey!, claudeCliPath,
allowedTools, allowedTools,
permissionMode, permissionMode,
queryTimeoutMs, queryTimeoutMs,

View File

@@ -8,7 +8,6 @@ describe("loadConfig", () => {
process.env = { ...originalEnv }; process.env = { ...originalEnv };
// Set required vars by default // Set required vars by default
process.env.DISCORD_BOT_TOKEN = "test-discord-token"; process.env.DISCORD_BOT_TOKEN = "test-discord-token";
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
}); });
afterEach(() => { afterEach(() => {
@@ -18,11 +17,11 @@ describe("loadConfig", () => {
it("should load required environment variables", () => { it("should load required environment variables", () => {
const config = loadConfig(); const config = loadConfig();
expect(config.discordBotToken).toBe("test-discord-token"); expect(config.discordBotToken).toBe("test-discord-token");
expect(config.anthropicApiKey).toBe("test-anthropic-key");
}); });
it("should apply default values for optional config", () => { it("should apply default values for optional config", () => {
const config = loadConfig(); const config = loadConfig();
expect(config.claudeCliPath).toBe("claude");
expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]); expect(config.allowedTools).toEqual(["Read", "Write", "Edit", "Glob", "Grep", "WebSearch", "WebFetch"]);
expect(config.permissionMode).toBe("bypassPermissions"); expect(config.permissionMode).toBe("bypassPermissions");
expect(config.queryTimeoutMs).toBe(120_000); expect(config.queryTimeoutMs).toBe(120_000);
@@ -51,6 +50,7 @@ describe("loadConfig", () => {
process.env.CONFIG_DIR = "/custom/config"; process.env.CONFIG_DIR = "/custom/config";
process.env.MAX_QUEUE_DEPTH = "200"; process.env.MAX_QUEUE_DEPTH = "200";
process.env.OUTPUT_CHANNEL_ID = "123456789"; process.env.OUTPUT_CHANNEL_ID = "123456789";
process.env.CLAUDE_CLI_PATH = "/usr/local/bin/claude";
const config = loadConfig(); const config = loadConfig();
expect(config.permissionMode).toBe("default"); expect(config.permissionMode).toBe("default");
@@ -59,6 +59,7 @@ describe("loadConfig", () => {
expect(config.configDir).toBe("/custom/config"); expect(config.configDir).toBe("/custom/config");
expect(config.maxQueueDepth).toBe(200); expect(config.maxQueueDepth).toBe(200);
expect(config.outputChannelId).toBe("123456789"); expect(config.outputChannelId).toBe("123456789");
expect(config.claudeCliPath).toBe("/usr/local/bin/claude");
}); });
it("should throw when DISCORD_BOT_TOKEN is missing", () => { it("should throw when DISCORD_BOT_TOKEN is missing", () => {
@@ -66,16 +67,10 @@ describe("loadConfig", () => {
expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN"); expect(() => loadConfig()).toThrow("DISCORD_BOT_TOKEN");
}); });
it("should throw when ANTHROPIC_API_KEY is missing", () => {
delete process.env.ANTHROPIC_API_KEY;
expect(() => loadConfig()).toThrow("ANTHROPIC_API_KEY");
});
it("should list all missing required variables in error message", () => { it("should list all missing required variables in error message", () => {
delete process.env.DISCORD_BOT_TOKEN; delete process.env.DISCORD_BOT_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
expect(() => loadConfig()).toThrow( expect(() => loadConfig()).toThrow(
"Missing required environment variables: DISCORD_BOT_TOKEN, ANTHROPIC_API_KEY" "Missing required environment variables: DISCORD_BOT_TOKEN"
); );
}); });
}); });