# Flynn Phase 1 Implementation Plan

> **Archived (2026-02-18):** Historical implementation checklist. Canonical status is tracked in `docs/plans/state.json`; unchecked boxes here are not active backlog unless explicitly re-opened.


> **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**

```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**

```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**

```typescript
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**

```bash
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```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**

```bash
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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**

```bash
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

```typescript
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**

```bash
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`:

```typescript
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`:

```typescript
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`:

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

**Step 5: Create backends index**

Create `src/backends/index.ts`:

```typescript
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**

```bash
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`:

```typescript
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`:

```typescript
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`:

```typescript
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`:

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

**Step 7: Commit**

```bash
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`:

```typescript
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`:

```typescript
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**

```bash
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`:

```bash
# 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**

```bash
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



