Files
flynn/docs/plans/2026-02-02-flynn-phase1-implementation.md
T
William Valentin 04fa56d3f1 docs: add Phase 1 implementation plan
8 tasks covering:
- Project scaffolding
- Config schema and loading
- Daemon lifecycle management
- Anthropic client wrapper
- Native agent with conversation history
- Telegram bot frontend
- Integration wiring
- Documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 20:49:55 -08:00

32 KiB

Flynn Phase 1 Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build the foundation - a working daemon that receives Telegram messages and responds via native agent using Anthropic API.

Architecture: TypeScript/Node.js daemon with grammY Telegram bot, YAML config loading, and a minimal native agent that calls Claude Sonnet. The daemon exposes an internal WebSocket API for future frontends.

Tech Stack: TypeScript, Node.js 22+, pnpm, grammY, @anthropic-ai/sdk, yaml, zod (config validation)


Task 1: Project Scaffolding

Files:

  • Create: package.json
  • Create: tsconfig.json
  • Create: .gitignore
  • Create: src/index.ts

Step 1: Initialize package.json

{
  "name": "flynn",
  "version": "0.1.0",
  "description": "Self-hosted personal AI agent",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "test": "vitest",
    "test:run": "vitest run",
    "lint": "eslint src/",
    "typecheck": "tsc --noEmit"
  },
  "keywords": ["ai", "agent", "telegram", "personal-assistant"],
  "license": "MIT",
  "devDependencies": {
    "@types/node": "^22.0.0",
    "eslint": "^9.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.39.0",
    "grammy": "^1.35.0",
    "yaml": "^2.7.0",
    "zod": "^3.24.0"
  },
  "engines": {
    "node": ">=22.0.0"
  }
}

Step 2: Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Step 3: Create .gitignore

node_modules/
dist/
*.log
.env
.env.*
!.env.example

Step 4: Create minimal src/index.ts

console.log('Flynn starting...');

Step 5: Install dependencies

Run: pnpm install Expected: Dependencies installed, lockfile created

Step 6: Verify build

Run: pnpm build Expected: dist/index.js created

Step 7: Commit

git add package.json tsconfig.json .gitignore src/index.ts pnpm-lock.yaml
git commit -m "chore: initialize project scaffolding"

Task 2: Config Schema and Loading

Files:

  • Create: src/config/schema.ts
  • Create: src/config/loader.ts
  • Create: src/config/index.ts
  • Create: config/default.yaml
  • Test: src/config/loader.test.ts

Step 1: Write failing test for config loading

Create src/config/loader.test.ts:

import { describe, it, expect } from 'vitest';
import { loadConfig } from './loader.js';
import { writeFileSync, mkdirSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';

describe('loadConfig', () => {
  const testDir = join(tmpdir(), 'flynn-test-config');

  it('loads valid config from file', () => {
    mkdirSync(testDir, { recursive: true });
    const configPath = join(testDir, 'config.yaml');
    writeFileSync(configPath, `
telegram:
  bot_token: "test-token"
  allowed_chat_ids: [123456789]
server:
  port: 18800
models:
  default:
    provider: anthropic
    model: claude-sonnet
`);

    const config = loadConfig(configPath);

    expect(config.telegram.bot_token).toBe('test-token');
    expect(config.telegram.allowed_chat_ids).toContain(123456789);
    expect(config.server.port).toBe(18800);
    expect(config.models.default.provider).toBe('anthropic');

    rmSync(testDir, { recursive: true });
  });

  it('expands environment variables', () => {
    mkdirSync(testDir, { recursive: true });
    const configPath = join(testDir, 'config.yaml');
    process.env.TEST_BOT_TOKEN = 'env-token-value';
    writeFileSync(configPath, `
telegram:
  bot_token: \${TEST_BOT_TOKEN}
  allowed_chat_ids: [123]
server:
  port: 18800
models:
  default:
    provider: anthropic
    model: claude-sonnet
`);

    const config = loadConfig(configPath);

    expect(config.telegram.bot_token).toBe('env-token-value');

    delete process.env.TEST_BOT_TOKEN;
    rmSync(testDir, { recursive: true });
  });

  it('throws on invalid config', () => {
    mkdirSync(testDir, { recursive: true });
    const configPath = join(testDir, 'config.yaml');
    writeFileSync(configPath, `
telegram:
  bot_token: ""
`);

    expect(() => loadConfig(configPath)).toThrow();

    rmSync(testDir, { recursive: true });
  });
});

Step 2: Run test to verify it fails

Run: pnpm test:run src/config/loader.test.ts Expected: FAIL - module not found

Step 3: Create config schema

Create src/config/schema.ts:

import { z } from 'zod';

const telegramSchema = z.object({
  bot_token: z.string().min(1, 'Bot token is required'),
  allowed_chat_ids: z.array(z.number()).min(1, 'At least one chat ID required'),
});

const serverSchema = z.object({
  tailscale_only: z.boolean().default(true),
  localhost: z.boolean().default(true),
  port: z.number().default(18800),
});

const modelConfigSchema = z.object({
  provider: z.enum(['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp']),
  model: z.string(),
  endpoint: z.string().optional(),
  for: z.array(z.string()).optional(),
});

const modelsSchema = z.object({
  local: modelConfigSchema.optional(),
  fast: modelConfigSchema.optional(),
  default: modelConfigSchema,
  complex: modelConfigSchema.optional(),
  fallback_chain: z.array(z.string()).default(['anthropic']),
});

const backendsSchema = z.object({
  claude_code: z.object({
    enabled: z.boolean().default(false),
    path: z.string().optional(),
  }).default({ enabled: false }),
  opencode: z.object({
    enabled: z.boolean().default(false),
    path: z.string().optional(),
  }).default({ enabled: false }),
  native: z.object({
    enabled: z.boolean().default(true),
  }).default({ enabled: true }),
}).default({});

const hooksSchema = z.object({
  confirm: z.array(z.string()).default([]),
  log: z.array(z.string()).default([]),
  silent: z.array(z.string()).default([]),
}).default({});

const mcpServerSchema = z.object({
  name: z.string(),
  command: z.string(),
  args: z.array(z.string()).default([]),
});

const mcpSchema = z.object({
  servers: z.array(mcpServerSchema).default([]),
}).default({ servers: [] });

export const configSchema = z.object({
  telegram: telegramSchema,
  server: serverSchema.default({}),
  models: modelsSchema,
  backends: backendsSchema.default({}),
  hooks: hooksSchema.default({}),
  mcp: mcpSchema.default({ servers: [] }),
});

export type Config = z.infer<typeof configSchema>;
export type TelegramConfig = z.infer<typeof telegramSchema>;
export type ModelConfig = z.infer<typeof modelConfigSchema>;

Step 4: Create config loader

Create src/config/loader.ts:

import { readFileSync } from 'fs';
import { parse } from 'yaml';
import { configSchema, type Config } from './schema.js';

function expandEnvVars(value: string): string {
  return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
    const envValue = process.env[envVar];
    if (envValue === undefined) {
      throw new Error(`Environment variable ${envVar} is not set`);
    }
    return envValue;
  });
}

function expandEnvVarsInObject(obj: unknown): unknown {
  if (typeof obj === 'string') {
    return expandEnvVars(obj);
  }
  if (Array.isArray(obj)) {
    return obj.map(expandEnvVarsInObject);
  }
  if (obj !== null && typeof obj === 'object') {
    const result: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(obj)) {
      result[key] = expandEnvVarsInObject(value);
    }
    return result;
  }
  return obj;
}

export function loadConfig(configPath: string): Config {
  const rawContent = readFileSync(configPath, 'utf-8');
  const rawConfig = parse(rawContent);
  const expandedConfig = expandEnvVarsInObject(rawConfig);
  return configSchema.parse(expandedConfig);
}

Step 5: Create config index

Create src/config/index.ts:

export { loadConfig } from './loader.js';
export { configSchema, type Config, type TelegramConfig, type ModelConfig } from './schema.js';

Step 6: Run test to verify it passes

Run: pnpm test:run src/config/loader.test.ts Expected: PASS

Step 7: Create default config template

Create config/default.yaml:

# Flynn Configuration
# Copy to ~/.config/flynn/config.yaml and customize

telegram:
  bot_token: ${FLYNN_TELEGRAM_TOKEN}
  allowed_chat_ids: []  # Add your Telegram chat ID

server:
  tailscale_only: true
  localhost: true
  port: 18800

models:
  default:
    provider: anthropic
    model: claude-sonnet-4-20250514

hooks:
  confirm:
    - shell.*
    - file.write
  log:
    - web.*
    - file.read
  silent:
    - notify

Step 8: Commit

git add src/config/ config/default.yaml
git commit -m "feat: add config schema and loader with env var expansion"

Task 3: Daemon Skeleton with Graceful Shutdown

Files:

  • Create: src/daemon/index.ts
  • Create: src/daemon/lifecycle.ts
  • Modify: src/index.ts
  • Test: src/daemon/lifecycle.test.ts

Step 1: Write failing test for lifecycle

Create src/daemon/lifecycle.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { Lifecycle } from './lifecycle.js';

describe('Lifecycle', () => {
  it('registers and calls shutdown handlers in reverse order', async () => {
    const lifecycle = new Lifecycle();
    const calls: number[] = [];

    lifecycle.onShutdown(async () => { calls.push(1); });
    lifecycle.onShutdown(async () => { calls.push(2); });
    lifecycle.onShutdown(async () => { calls.push(3); });

    await lifecycle.shutdown();

    expect(calls).toEqual([3, 2, 1]);
  });

  it('only shuts down once', async () => {
    const lifecycle = new Lifecycle();
    let count = 0;

    lifecycle.onShutdown(async () => { count++; });

    await lifecycle.shutdown();
    await lifecycle.shutdown();

    expect(count).toBe(1);
  });

  it('reports running state', async () => {
    const lifecycle = new Lifecycle();

    expect(lifecycle.isRunning).toBe(true);

    await lifecycle.shutdown();

    expect(lifecycle.isRunning).toBe(false);
  });
});

Step 2: Run test to verify it fails

Run: pnpm test:run src/daemon/lifecycle.test.ts Expected: FAIL - module not found

Step 3: Implement lifecycle manager

Create src/daemon/lifecycle.ts:

type ShutdownHandler = () => Promise<void>;

export class Lifecycle {
  private shutdownHandlers: ShutdownHandler[] = [];
  private shuttingDown = false;
  private _isRunning = true;

  get isRunning(): boolean {
    return this._isRunning;
  }

  onShutdown(handler: ShutdownHandler): void {
    this.shutdownHandlers.push(handler);
  }

  async shutdown(): Promise<void> {
    if (this.shuttingDown) return;
    this.shuttingDown = true;
    this._isRunning = false;

    console.log('Shutting down...');

    // Execute handlers in reverse order (LIFO)
    for (const handler of [...this.shutdownHandlers].reverse()) {
      try {
        await handler();
      } catch (error) {
        console.error('Shutdown handler error:', error);
      }
    }

    console.log('Shutdown complete');
  }
}

Step 4: Run test to verify it passes

Run: pnpm test:run src/daemon/lifecycle.test.ts Expected: PASS

Step 5: Create daemon entry

Create src/daemon/index.ts:

import { Lifecycle } from './lifecycle.js';
import type { Config } from '../config/index.js';

export interface DaemonContext {
  config: Config;
  lifecycle: Lifecycle;
}

export async function startDaemon(config: Config): Promise<DaemonContext> {
  const lifecycle = new Lifecycle();

  // Register signal handlers
  const signalHandler = () => {
    lifecycle.shutdown().then(() => process.exit(0));
  };

  process.on('SIGINT', signalHandler);
  process.on('SIGTERM', signalHandler);

  lifecycle.onShutdown(async () => {
    process.off('SIGINT', signalHandler);
    process.off('SIGTERM', signalHandler);
  });

  console.log('Flynn daemon started');

  return { config, lifecycle };
}

export { Lifecycle } from './lifecycle.js';

Step 6: Update main entry

Replace src/index.ts:

import { loadConfig } from './config/index.js';
import { startDaemon } from './daemon/index.js';
import { resolve } from 'path';
import { homedir } from 'os';

const CONFIG_PATH = process.env.FLYNN_CONFIG
  ?? resolve(homedir(), '.config/flynn/config.yaml');

async function main() {
  console.log('Flynn starting...');
  console.log(`Loading config from: ${CONFIG_PATH}`);

  try {
    const config = loadConfig(CONFIG_PATH);
    const daemon = await startDaemon(config);

    console.log(`Telegram bot configured for chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`);
    console.log(`Server port: ${config.server.port}`);

    // Keep process alive
    await new Promise<void>((resolve) => {
      daemon.lifecycle.onShutdown(async () => resolve());
    });
  } catch (error) {
    console.error('Failed to start Flynn:', error);
    process.exit(1);
  }
}

main();

Step 7: Verify build

Run: pnpm build Expected: No errors

Step 8: Commit

git add src/daemon/ src/index.ts
git commit -m "feat: add daemon skeleton with lifecycle management"

Task 4: Anthropic Client Wrapper

Files:

  • Create: src/models/anthropic.ts
  • Create: src/models/types.ts
  • Create: src/models/index.ts
  • Test: src/models/anthropic.test.ts

Step 1: Create types

Create src/models/types.ts:

export interface Message {
  role: 'user' | 'assistant';
  content: string;
}

export interface ChatRequest {
  messages: Message[];
  system?: string;
  maxTokens?: number;
}

export interface ChatResponse {
  content: string;
  stopReason: 'end_turn' | 'max_tokens' | 'stop_sequence' | string;
  usage: {
    inputTokens: number;
    outputTokens: number;
  };
}

export interface ModelClient {
  chat(request: ChatRequest): Promise<ChatResponse>;
}

Step 2: Write failing test

Create src/models/anthropic.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AnthropicClient } from './anthropic.js';

// Mock the SDK
vi.mock('@anthropic-ai/sdk', () => ({
  default: vi.fn().mockImplementation(() => ({
    messages: {
      create: vi.fn().mockResolvedValue({
        content: [{ type: 'text', text: 'Hello from Claude!' }],
        stop_reason: 'end_turn',
        usage: { input_tokens: 10, output_tokens: 5 },
      }),
    },
  })),
}));

describe('AnthropicClient', () => {
  it('sends messages and returns response', async () => {
    const client = new AnthropicClient({
      apiKey: 'test-key',
      model: 'claude-sonnet-4-20250514',
    });

    const response = await client.chat({
      messages: [{ role: 'user', content: 'Hello' }],
    });

    expect(response.content).toBe('Hello from Claude!');
    expect(response.stopReason).toBe('end_turn');
    expect(response.usage.inputTokens).toBe(10);
    expect(response.usage.outputTokens).toBe(5);
  });
});

Step 3: Run test to verify it fails

Run: pnpm test:run src/models/anthropic.test.ts Expected: FAIL - module not found

Step 4: Implement Anthropic client

Create src/models/anthropic.ts:

import Anthropic from '@anthropic-ai/sdk';
import type { ChatRequest, ChatResponse, ModelClient } from './types.js';

export interface AnthropicClientConfig {
  apiKey?: string;  // Falls back to ANTHROPIC_API_KEY env var
  model: string;
  maxTokens?: number;
}

export class AnthropicClient implements ModelClient {
  private client: Anthropic;
  private model: string;
  private defaultMaxTokens: number;

  constructor(config: AnthropicClientConfig) {
    this.client = new Anthropic({
      apiKey: config.apiKey,
    });
    this.model = config.model;
    this.defaultMaxTokens = config.maxTokens ?? 4096;
  }

  async chat(request: ChatRequest): Promise<ChatResponse> {
    const response = await this.client.messages.create({
      model: this.model,
      max_tokens: request.maxTokens ?? this.defaultMaxTokens,
      system: request.system,
      messages: request.messages.map((m) => ({
        role: m.role,
        content: m.content,
      })),
    });

    const textContent = response.content.find((c) => c.type === 'text');
    const content = textContent?.type === 'text' ? textContent.text : '';

    return {
      content,
      stopReason: response.stop_reason ?? 'end_turn',
      usage: {
        inputTokens: response.usage.input_tokens,
        outputTokens: response.usage.output_tokens,
      },
    };
  }
}

Step 5: Create models index

Create src/models/index.ts:

export { AnthropicClient, type AnthropicClientConfig } from './anthropic.js';
export type { Message, ChatRequest, ChatResponse, ModelClient } from './types.js';

Step 6: Run test to verify it passes

Run: pnpm test:run src/models/anthropic.test.ts Expected: PASS

Step 7: Commit

git add src/models/
git commit -m "feat: add Anthropic client wrapper"

Task 5: Native Agent

Files:

  • Create: src/backends/native/agent.ts
  • Create: src/backends/native/index.ts
  • Create: src/backends/index.ts
  • Test: src/backends/native/agent.test.ts

Step 1: Write failing test

Create src/backends/native/agent.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { NativeAgent } from './agent.js';
import type { ModelClient, ChatResponse } from '../../models/types.js';

describe('NativeAgent', () => {
  it('processes message and returns response', async () => {
    const mockClient: ModelClient = {
      chat: vi.fn().mockResolvedValue({
        content: 'Hello! How can I help you?',
        stopReason: 'end_turn',
        usage: { inputTokens: 10, outputTokens: 8 },
      } satisfies ChatResponse),
    };

    const agent = new NativeAgent({
      modelClient: mockClient,
      systemPrompt: 'You are Flynn, a helpful assistant.',
    });

    const response = await agent.process('Hello');

    expect(response).toBe('Hello! How can I help you?');
    expect(mockClient.chat).toHaveBeenCalledWith({
      messages: [{ role: 'user', content: 'Hello' }],
      system: 'You are Flynn, a helpful assistant.',
    });
  });

  it('maintains conversation history', async () => {
    const mockClient: ModelClient = {
      chat: vi.fn().mockResolvedValue({
        content: 'Response',
        stopReason: 'end_turn',
        usage: { inputTokens: 10, outputTokens: 5 },
      } satisfies ChatResponse),
    };

    const agent = new NativeAgent({
      modelClient: mockClient,
      systemPrompt: 'System',
    });

    await agent.process('First message');
    await agent.process('Second message');

    expect(mockClient.chat).toHaveBeenLastCalledWith({
      messages: [
        { role: 'user', content: 'First message' },
        { role: 'assistant', content: 'Response' },
        { role: 'user', content: 'Second message' },
      ],
      system: 'System',
    });
  });

  it('resets conversation history', async () => {
    const mockClient: ModelClient = {
      chat: vi.fn().mockResolvedValue({
        content: 'Response',
        stopReason: 'end_turn',
        usage: { inputTokens: 10, outputTokens: 5 },
      } satisfies ChatResponse),
    };

    const agent = new NativeAgent({
      modelClient: mockClient,
      systemPrompt: 'System',
    });

    await agent.process('Message 1');
    agent.reset();
    await agent.process('Message 2');

    expect(mockClient.chat).toHaveBeenLastCalledWith({
      messages: [{ role: 'user', content: 'Message 2' }],
      system: 'System',
    });
  });
});

Step 2: Run test to verify it fails

Run: pnpm test:run src/backends/native/agent.test.ts Expected: FAIL - module not found

Step 3: Implement native agent

Create src/backends/native/agent.ts:

import type { ModelClient, Message } from '../../models/types.js';

export interface NativeAgentConfig {
  modelClient: ModelClient;
  systemPrompt: string;
}

export class NativeAgent {
  private modelClient: ModelClient;
  private systemPrompt: string;
  private history: Message[] = [];

  constructor(config: NativeAgentConfig) {
    this.modelClient = config.modelClient;
    this.systemPrompt = config.systemPrompt;
  }

  async process(userMessage: string): Promise<string> {
    this.history.push({ role: 'user', content: userMessage });

    const response = await this.modelClient.chat({
      messages: [...this.history],
      system: this.systemPrompt,
    });

    this.history.push({ role: 'assistant', content: response.content });

    return response.content;
  }

  reset(): void {
    this.history = [];
  }

  getHistory(): Message[] {
    return [...this.history];
  }
}

Step 4: Create native index

Create src/backends/native/index.ts:

export { NativeAgent, type NativeAgentConfig } from './agent.js';

Step 5: Create backends index

Create src/backends/index.ts:

export { NativeAgent, type NativeAgentConfig } from './native/index.js';

Step 6: Run test to verify it passes

Run: pnpm test:run src/backends/native/agent.test.ts Expected: PASS

Step 7: Commit

git add src/backends/
git commit -m "feat: add native agent with conversation history"

Task 6: Telegram Bot Frontend

Files:

  • Create: src/frontends/telegram/bot.ts
  • Create: src/frontends/telegram/handlers.ts
  • Create: src/frontends/telegram/index.ts
  • Test: src/frontends/telegram/handlers.test.ts

Step 1: Write failing test for handlers

Create src/frontends/telegram/handlers.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { createMessageHandler, isAllowedChat } from './handlers.js';
import type { NativeAgent } from '../../backends/native/agent.js';

describe('isAllowedChat', () => {
  it('returns true for allowed chat ID', () => {
    expect(isAllowedChat(123, [123, 456])).toBe(true);
  });

  it('returns false for disallowed chat ID', () => {
    expect(isAllowedChat(789, [123, 456])).toBe(false);
  });
});

describe('createMessageHandler', () => {
  it('processes message and returns response', async () => {
    const mockAgent: NativeAgent = {
      process: vi.fn().mockResolvedValue('Agent response'),
      reset: vi.fn(),
      getHistory: vi.fn(),
    } as unknown as NativeAgent;

    const handler = createMessageHandler(mockAgent);
    const response = await handler('Hello');

    expect(response).toBe('Agent response');
    expect(mockAgent.process).toHaveBeenCalledWith('Hello');
  });
});

Step 2: Run test to verify it fails

Run: pnpm test:run src/frontends/telegram/handlers.test.ts Expected: FAIL - module not found

Step 3: Implement handlers

Create src/frontends/telegram/handlers.ts:

import type { NativeAgent } from '../../backends/index.js';

export function isAllowedChat(chatId: number, allowedIds: number[]): boolean {
  return allowedIds.includes(chatId);
}

export function createMessageHandler(agent: NativeAgent): (text: string) => Promise<string> {
  return async (text: string): Promise<string> => {
    return agent.process(text);
  };
}

export function createResetHandler(agent: NativeAgent): () => void {
  return (): void => {
    agent.reset();
  };
}

Step 4: Run test to verify it passes

Run: pnpm test:run src/frontends/telegram/handlers.test.ts Expected: PASS

Step 5: Implement Telegram bot

Create src/frontends/telegram/bot.ts:

import { Bot, Context } from 'grammy';
import type { NativeAgent } from '../../backends/index.js';
import type { TelegramConfig } from '../../config/index.js';
import { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';

export interface TelegramBotConfig {
  telegram: TelegramConfig;
  agent: NativeAgent;
}

export function createTelegramBot(config: TelegramBotConfig): Bot {
  const bot = new Bot(config.telegram.bot_token);
  const handleMessage = createMessageHandler(config.agent);
  const handleReset = createResetHandler(config.agent);
  const allowedChatIds = config.telegram.allowed_chat_ids;

  // Middleware to check chat ID
  bot.use(async (ctx, next) => {
    const chatId = ctx.chat?.id;
    if (chatId === undefined || !isAllowedChat(chatId, allowedChatIds)) {
      console.log(`Rejected message from unauthorized chat: ${chatId}`);
      return;
    }
    await next();
  });

  // Command handlers
  bot.command('start', async (ctx) => {
    await ctx.reply('Flynn is ready. Send me a message!');
  });

  bot.command('reset', async (ctx) => {
    handleReset();
    await ctx.reply('Conversation reset.');
  });

  bot.command('status', async (ctx) => {
    await ctx.reply('Flynn is running.');
  });

  // Message handler
  bot.on('message:text', async (ctx) => {
    const text = ctx.message.text;

    // Show typing indicator
    await ctx.replyWithChatAction('typing');

    try {
      const response = await handleMessage(text);

      // Telegram has a 4096 character limit per message
      if (response.length <= 4096) {
        await ctx.reply(response, { parse_mode: 'Markdown' });
      } else {
        // Split into chunks
        const chunks = splitMessage(response, 4096);
        for (const chunk of chunks) {
          await ctx.reply(chunk, { parse_mode: 'Markdown' });
        }
      }
    } catch (error) {
      console.error('Error processing message:', error);
      await ctx.reply('Sorry, an error occurred while processing your message.');
    }
  });

  return bot;
}

function splitMessage(text: string, maxLength: number): string[] {
  const chunks: string[] = [];
  let remaining = text;

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

    // Try to split at a newline
    let splitIndex = remaining.lastIndexOf('\n', maxLength);
    if (splitIndex === -1 || splitIndex < maxLength / 2) {
      // Fall back to splitting at space
      splitIndex = remaining.lastIndexOf(' ', maxLength);
    }
    if (splitIndex === -1 || splitIndex < maxLength / 2) {
      // Hard split
      splitIndex = maxLength;
    }

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

  return chunks;
}

Step 6: Create telegram index

Create src/frontends/telegram/index.ts:

export { createTelegramBot, type TelegramBotConfig } from './bot.js';
export { isAllowedChat, createMessageHandler, createResetHandler } from './handlers.js';

Step 7: Commit

git add src/frontends/
git commit -m "feat: add Telegram bot frontend with message handling"

Task 7: Wire Everything Together

Files:

  • Modify: src/daemon/index.ts
  • Modify: src/index.ts

Step 1: Update daemon to initialize components

Replace src/daemon/index.ts:

import { Bot } from 'grammy';
import { Lifecycle } from './lifecycle.js';
import type { Config } from '../config/index.js';
import { AnthropicClient } from '../models/index.js';
import { NativeAgent } from '../backends/index.js';
import { createTelegramBot } from '../frontends/telegram/index.js';

export interface DaemonContext {
  config: Config;
  lifecycle: Lifecycle;
  bot: Bot;
  agent: NativeAgent;
}

const SYSTEM_PROMPT = `You are Flynn, a helpful personal AI assistant. You are direct, concise, and helpful. You can help with a variety of tasks including answering questions, providing information, and having conversations.

Keep responses focused and avoid unnecessary verbosity. Use markdown formatting when it improves readability.`;

export async function startDaemon(config: Config): Promise<DaemonContext> {
  const lifecycle = new Lifecycle();

  // Initialize model client
  const modelClient = new AnthropicClient({
    model: config.models.default.model,
  });

  // Initialize native agent
  const agent = new NativeAgent({
    modelClient,
    systemPrompt: SYSTEM_PROMPT,
  });

  // Initialize Telegram bot
  const bot = createTelegramBot({
    telegram: config.telegram,
    agent,
  });

  // Register signal handlers
  const signalHandler = () => {
    lifecycle.shutdown().then(() => process.exit(0));
  };

  process.on('SIGINT', signalHandler);
  process.on('SIGTERM', signalHandler);

  lifecycle.onShutdown(async () => {
    process.off('SIGINT', signalHandler);
    process.off('SIGTERM', signalHandler);
  });

  // Start bot
  lifecycle.onShutdown(async () => {
    await bot.stop();
    console.log('Telegram bot stopped');
  });

  // Use long polling (no webhook, no internet exposure)
  bot.start({
    onStart: (botInfo) => {
      console.log(`Telegram bot started: @${botInfo.username}`);
    },
  });

  console.log('Flynn daemon started');

  return { config, lifecycle, bot, agent };
}

export { Lifecycle } from './lifecycle.js';

Step 2: Update main entry

Replace src/index.ts:

import { loadConfig } from './config/index.js';
import { startDaemon } from './daemon/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import { existsSync } from 'fs';

const CONFIG_PATH = process.env.FLYNN_CONFIG
  ?? resolve(homedir(), '.config/flynn/config.yaml');

async function main() {
  console.log('Flynn starting...');
  console.log(`Loading config from: ${CONFIG_PATH}`);

  if (!existsSync(CONFIG_PATH)) {
    console.error(`Config file not found: ${CONFIG_PATH}`);
    console.error('Copy config/default.yaml to ~/.config/flynn/config.yaml and configure it.');
    process.exit(1);
  }

  try {
    const config = loadConfig(CONFIG_PATH);
    const daemon = await startDaemon(config);

    console.log(`Allowed Telegram chat IDs: ${config.telegram.allowed_chat_ids.join(', ')}`);

    // Keep process alive
    await new Promise<void>((resolve) => {
      daemon.lifecycle.onShutdown(async () => resolve());
    });
  } catch (error) {
    console.error('Failed to start Flynn:', error);
    process.exit(1);
  }
}

main();

Step 3: Verify build

Run: pnpm build Expected: No errors

Step 4: Run all tests

Run: pnpm test:run Expected: All tests pass

Step 5: Commit

git add src/daemon/index.ts src/index.ts
git commit -m "feat: wire daemon, agent, and telegram bot together"

Task 8: Integration Test & Documentation

Files:

  • Create: .env.example
  • Modify: config/default.yaml

Step 1: Create .env.example

Create .env.example:

# Telegram Bot Token from @BotFather
FLYNN_TELEGRAM_TOKEN=your-bot-token-here

# Anthropic API Key
ANTHROPIC_API_KEY=your-anthropic-api-key-here

# Optional: Custom config path
# FLYNN_CONFIG=/path/to/config.yaml

Step 2: Verify final build

Run: pnpm build && pnpm test:run Expected: Build succeeds, all tests pass

Step 3: Final commit

git add .env.example
git commit -m "docs: add environment variable example"

Verification Checklist

After completing all tasks, verify:

  1. pnpm build succeeds with no errors
  2. pnpm test:run passes all tests
  3. pnpm lint passes (if eslint configured)
  4. Config loads from ~/.config/flynn/config.yaml
  5. Environment variables are expanded in config
  6. Bot starts and responds to /start command
  7. Only allowed chat IDs can interact
  8. Messages are processed by native agent
  9. /reset clears conversation history
  10. Graceful shutdown on SIGINT/SIGTERM

Manual Testing Steps

  1. Create ~/.config/flynn/config.yaml from config/default.yaml
  2. Set FLYNN_TELEGRAM_TOKEN and ANTHROPIC_API_KEY
  3. Add your Telegram chat ID to allowed_chat_ids
  4. Run pnpm dev
  5. Send /start to your bot
  6. Send a test message
  7. Verify response from Claude
  8. Send /reset to clear history
  9. Press Ctrl+C to verify graceful shutdown