> ## Documentation Index
> Fetch the complete documentation index at: https://docs.echos.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Building a Plugin

> Extend EchOS with custom content processors and agent tools.

# Creating a Plugin

Plugins add content processors and agent tools to EchOS without modifying core code. Each plugin is a separate workspace package in `plugins/`.

## Steps

<Steps>
  <Step title="Scaffold the package">
    ```bash theme={null}
    mkdir -p plugins/my-plugin/src
    ```

    Create `plugins/my-plugin/package.json`:

    ```json theme={null}
    {
      "name": "@echos/plugin-my-plugin",
      "version": "0.1.0",
      "private": true,
      "type": "module",
      "main": "./dist/index.js",
      "types": "./dist/index.d.ts",
      "scripts": {
        "build": "tsc",
        "dev": "tsc --watch",
        "typecheck": "tsc --noEmit",
        "clean": "rm -rf dist"
      },
      "dependencies": {
        "@echos/shared": "workspace:*",
        "@echos/core": "workspace:*",
        "@mariozechner/pi-agent-core": "^0.52.12",
        "@mariozechner/pi-ai": "^0.52.12",
        "pino": "^9.14.0",
        "uuid": "^13.0.0"
      },
      "devDependencies": {
        "@types/node": "^25.2.3",
        "@types/uuid": "^11.0.0",
        "typescript": "^5.7.0"
      }
    }
    ```

    Create `plugins/my-plugin/tsconfig.json`:

    ```json theme={null}
    {
      "extends": "../../tsconfig.json",
      "compilerOptions": {
        "outDir": "./dist",
        "rootDir": "./src"
      },
      "include": ["src"]
    }
    ```
  </Step>

  <Step title="Write the processor">
    Create `plugins/my-plugin/src/processor.ts` — the logic that fetches/transforms external content:

    ```typescript theme={null}
    import type { Logger } from 'pino';
    import { validateUrl, sanitizeHtml } from '@echos/shared';
    import type { ProcessedContent } from '@echos/shared';

    export async function processMyContent(
      url: string,
      logger: Logger,
    ): Promise<ProcessedContent> {
      const validatedUrl = validateUrl(url); // SSRF prevention — required
      logger.info({ url: validatedUrl }, 'Processing content');

      // Fetch and extract content...
      const title = sanitizeHtml(rawTitle);   // Always sanitize external content
      const content = sanitizeHtml(rawContent);

      return {
        title,
        content,
        metadata: {
          type: 'note', // Use an existing ContentType or extend types
          sourceUrl: validatedUrl,
        },
        embedText: `${title}\n\n${content.slice(0, 3000)}`,
      };
    }
    ```

    **Security rules** (non-negotiable):

    * Always use `validateUrl()` before fetching any URL
    * Always use `sanitizeHtml()` on external content
    * Never use `eval()`, `Function()`, or `vm`
    * Never log secrets
  </Step>

  <Step title="Create the agent tool">
    Create `plugins/my-plugin/src/tool.ts` — defines the tool the LLM agent can call:

    ```typescript theme={null}
    import { Type, type Static } from '@mariozechner/pi-ai';
    import type { AgentTool } from '@mariozechner/pi-agent-core';
    import { v4 as uuidv4 } from 'uuid';
    import type { NoteMetadata } from '@echos/shared';
    import type { PluginContext } from '@echos/core';
    import { categorizeContent, type ProcessingMode } from '@echos/core';
    import { processMyContent } from './processor.js';

    const schema = Type.Object({
      url: Type.String({ description: 'URL to process' }),
      tags: Type.Optional(Type.Array(Type.String(), { description: 'Tags' })),
      category: Type.Optional(Type.String({ description: 'Category' })),
      autoCategorize: Type.Optional(
        Type.Boolean({
          description: 'Automatically categorize using AI (default: false)',
          default: false,
        }),
      ),
      processingMode: Type.Optional(
        Type.Union([Type.Literal('lightweight'), Type.Literal('full')], {
          description: 'AI processing mode: "lightweight" (category+tags) or "full" (includes summary, gist, key points). Only used if autoCategorize is true.',
          default: 'full',
        }),
      ),
    });

    type Params = Static<typeof schema>;

    export function createMyTool(
      context: PluginContext,
    ): AgentTool<typeof schema> {
      return {
        name: 'save_my_content',
        label: 'Save My Content',
        description: 'Describe what this tool does — the agent reads this to decide when to use it. Optionally auto-categorize with AI.',
        parameters: schema,
        execute: async (_toolCallId, params: Params, _signal, onUpdate) => {
          onUpdate?.({
            content: [{ type: 'text', text: `Processing ${params.url}...` }],
            details: { phase: 'fetching' },
          });

          const processed = await processMyContent(params.url, context.logger);

          const now = new Date().toISOString();
          const id = uuidv4();

          let category = params.category ?? 'uncategorized';
          let tags = params.tags ?? [];
          let gist: string | undefined;

          // Auto-categorize if requested and API key available
          if (params.autoCategorize && context.config.anthropicApiKey) {
            onUpdate?.({
              content: [{ type: 'text', text: 'Categorizing content with AI...' }],
              details: { phase: 'categorizing' },
            });

            try {
              const mode: ProcessingMode = params.processingMode ?? 'full';
              const result = await categorizeContent(
                processed.title,
                processed.content,
                mode,
                context.config.anthropicApiKey as string,
                context.logger,
                (message) => onUpdate?.({ content: [{ type: 'text', text: message }], details: { phase: 'categorizing' } }),
              );

              category = result.category;
              tags = result.tags;

              if ('gist' in result) {
                gist = result.gist;
              }

              context.logger.info(
                { category, tags, mode },
                'Content auto-categorized',
              );
            } catch (error) {
              context.logger.error({ error }, 'Auto-categorization failed, using defaults');
            }
          }

          const metadata: NoteMetadata = {
            id,
            type: 'note',
            title: processed.title,
            created: now,
            updated: now,
            tags,
            links: [],
            category,
            sourceUrl: params.url,
          };
          if (gist) metadata.gist = gist;

          // Save to all three storage layers
          const filePath = context.markdown.save(metadata, processed.content);
          context.sqlite.upsertNote(metadata, processed.content, filePath);

          if (processed.embedText) {
            try {
              const vector = await context.generateEmbedding(processed.embedText);
              await context.vectorDb.upsert({
                id,
                text: processed.embedText,
                vector,
                type: metadata.type,
                title: processed.title,
              });
            } catch {
              // Embedding failure is non-fatal
            }
          }

          return {
            content: [
              {
                type: 'text' as const,
                text: `Saved "${processed.title}" (id: ${id})\nCategory: ${category}\nTags: [${tags.join(', ')}]${gist ? `\nGist: ${gist}` : ''}`,
              },
            ],
            details: { id, filePath, title: processed.title, category, tags },
          };
        },
      };
    }
    ```

    **New in this example**: AI-powered auto-categorization support using the `categorizeContent` function from `@echos/core`. When `autoCategorize=true`, the plugin will automatically extract category, tags, and optionally gist/summary/key points from the content.
  </Step>

  <Step title="Export the plugin">
    Create `plugins/my-plugin/src/index.ts`:

    ```typescript theme={null}
    import type { EchosPlugin, PluginContext } from '@echos/core';
    import { createMyTool } from './tool.js';

    const myPlugin: EchosPlugin = {
      name: 'my-plugin',
      description: 'What this plugin does',
      version: '0.1.0',

      setup(context: PluginContext) {
        return [createMyTool(context)];
      },

      // Optional: cleanup on shutdown
      // teardown() { ... },
    };

    export default myPlugin;
    ```
  </Step>

  <Step title="Auto-discovery">
    Plugins are **auto-discovered** at runtime — `src/plugin-loader.ts` scans the `plugins/` directory and dynamically imports any `@echos/plugin-<dirname>` package. No manual import or registration in `src/index.ts` is needed.
  </Step>

  <Step title="Wire up the workspace">
    Add the path mapping to root `tsconfig.json`:

    ```json theme={null}
    {
      "compilerOptions": {
        "paths": {
          "@echos/plugin-my-plugin": ["./plugins/my-plugin/src/index.ts"]
        }
      }
    }
    ```

    Install dependencies:

    ```bash theme={null}
    pnpm install
    ```

    Build and verify:

    ```bash theme={null}
    pnpm -r build
    pnpm test
    ```
  </Step>
</Steps>

## PluginContext API

Every plugin receives a `PluginContext` with:

| Property            | Type                                  | Description                                |
| ------------------- | ------------------------------------- | ------------------------------------------ |
| `sqlite`            | `SqliteStorage`                       | Metadata DB (upsert, query, FTS5 search)   |
| `markdown`          | `MarkdownStorage`                     | Markdown file storage (save, read, delete) |
| `vectorDb`          | `VectorStorage`                       | Vector embeddings (upsert, search)         |
| `generateEmbedding` | `(text: string) => Promise<number[]>` | Generate embedding vectors                 |
| `logger`            | `Logger` (Pino)                       | Structured logger                          |
| `config`            | `Record<string, unknown>`             | App config (API keys, etc.)                |

## AI Categorization

Plugins can use the built-in categorization service from `@echos/core`:

```typescript theme={null}
import { categorizeContent, type ProcessingMode } from '@echos/core';

// Lightweight mode: category + tags only
const result = await categorizeContent(
  title,
  content,
  'lightweight',
  context.config.anthropicApiKey as string,
  context.logger,
  // Optional: receive progressive updates as the LLM streams its response
  (message) => onUpdate?.({ content: [{ type: 'text', text: message }], details: { phase: 'categorizing' } }),
);
// result: { category: string, tags: string[] }

// Full mode: includes gist, summary, key points
const fullResult = await categorizeContent(
  title,
  content,
  'full',
  context.config.anthropicApiKey as string,
  context.logger,
  (message) => onUpdate?.({ content: [{ type: 'text', text: message }], details: { phase: 'categorizing' } }),
);
// fullResult: { category, tags, gist, summary, keyPoints }
```

The categorization service:

* Uses `streamSimple` + `parseStreamingJson` to stream the LLM response progressively
* Fires `onProgress` as fields resolve: category → tags → gist (full mode)
* Handles errors with safe defaults (fallback to 'uncategorized')
* Respects content length limits (5000 chars for lightweight, 10000 for full)
* Is safe to call without `onProgress` — callers that don't need streaming omit the last argument

See [Categorization](/categorization) for detailed documentation.

## Existing plugins

| Plugin    | Package                   | Description                                                           |
| --------- | ------------------------- | --------------------------------------------------------------------- |
| YouTube   | `@echos/plugin-youtube`   | Transcript extraction + Whisper fallback                              |
| Article   | `@echos/plugin-article`   | Web article extraction via Readability + DOMPurify                    |
| Twitter   | `@echos/plugin-twitter`   | Tweet/thread extraction via FxTwitter API                             |
| Image     | `@echos/plugin-image`     | Image storage with metadata extraction (Sharp)                        |
| Resurface | `@echos/plugin-resurface` | Knowledge resurfacing via spaced repetition and on-this-day discovery |
| Journal   | `@echos/plugin-journal`   | Dedicated journaling, AI reflection, and daily prompts                |
| PDF       | `@echos/plugin-pdf`       | PDF text extraction via pdf-parse                                     |
| Audio     | `@echos/plugin-audio`     | Podcast/audio transcription via OpenAI Whisper                        |
| RSS Feeds | `@echos/plugin-rss`       | Automated RSS/Atom feed subscription and article ingestion            |

### Twitter Plugin

The Twitter plugin (`@echos/plugin-twitter`) provides the `save_tweet` tool for saving tweets and threads from Twitter/X.

**Features:**

* Save individual tweets with full metadata (text, author, engagement stats, media URLs)
* Automatic thread unrolling — reply chains by the same author are merged into a clean article
* Quote tweet extraction
* Media URLs referenced as markdown links (images and videos)
* Optional AI categorization for automatic tagging
* No API key required — uses the free FxTwitter API (`api.fxtwitter.com`)

**Supported URL formats:**

* `twitter.com/<user>/status/<id>`
* `x.com/<user>/status/<id>`
* `mobile.twitter.com/<user>/status/<id>`
* `fxtwitter.com/<user>/status/<id>`
* `vxtwitter.com/<user>/status/<id>`
* All formats support query parameters (`?s=20`, `?t=...`)

**Tool: save\_tweet**

```typescript theme={null}
{
  url: string;              // Twitter/X URL (required)
  tags?: string[];          // Array of tags
  category?: string;        // Category (default: "tweets")
  autoCategorize?: boolean; // Use AI to categorize (default: true)
  processingMode?: 'lightweight' | 'full'; // AI processing mode
}
```

**Thread unrolling:**

* Walks up the reply chain via `replying_to_status` to find earlier tweets by the same author
* Stops when a different author is reached or the chain exceeds 25 tweets
* Merges thread tweets into a clean article format, stripping self-reply @mentions
* Single tweets (no thread) are saved in blockquote format with engagement stats

### Image Plugin

The image plugin (`@echos/plugin-image`) provides the `save_image` tool for storing and organizing images in the knowledge base.

**Features:**

* Download images from URLs or accept base64 data
* Extract metadata: dimensions, format, file size, EXIF
* Store original files in `knowledge/image/{category}/`
* Create searchable markdown notes with image references
* Optional AI categorization for automatic tagging

**Tool: save\_image**

```typescript theme={null}
{
  imageUrl?: string;        // URL to download image from
  imageData?: string;       // Base64-encoded image data
  title?: string;           // Image title
  caption?: string;         // Description or context
  tags?: string[];          // Array of tags
  category?: string;        // Category (default: "photos")
  autoCategorize?: boolean; // Use AI to categorize (default: false)
  processingMode?: 'lightweight' | 'full'; // AI processing mode
}
```

**Supported formats:**

* JPEG, PNG, GIF, WebP, AVIF, TIFF, BMP
* Maximum size: 20MB

**Processor (`processImage`):**

* Validates image format and size
* Extracts metadata using Sharp library
* Generates content-based filename hash
* Returns structured metadata and buffer

**Storage:**

* Original file: `knowledge/image/{category}/{hash}.{ext}`
* Markdown note: `knowledge/note/{category}/{date}-{slug}.md`
* Embedded reference: `![title](../../image/{category}/{filename})`

**Telegram Integration:**

* Automatic photo handler via `bot.on('message:photo')`
* Downloads from Telegram API
* Passes URL to save\_image tool
* Supports captions for context

See [Images](/images) for complete documentation on image handling.

### Resurface Plugin

The resurface plugin (`@echos/plugin-resurface`) brings forgotten knowledge back to the surface through spaced repetition and serendipitous discovery. It provides a `get_resurfaced` agent tool and a daily scheduled job that broadcasts notes via Telegram.

**Features:**

* Three resurfacing strategies:
  * `forgotten` — notes you haven't seen in 7+ days, oldest first (classic spaced repetition)
  * `on_this_day` — notes created on the same calendar date in a prior year
  * `mix` (default) — blend of both for maximum serendipity
  * `random` — random sampling of un-recently-surfaced notes (supported by both the `get_resurfaced` tool and scheduler config)
* Tracks a `last_surfaced` timestamp per note in SQLite — never resurfaces the same note twice within 7 days
* Daily broadcast job sends 2–3 notes to Telegram with emoji labels (🔮 Resurfaced, 📅 On this day, 🎲 Discovery)
* On-demand access via the `get_resurfaced` tool

**Tool: get\_resurfaced**

```typescript theme={null}
{
  mode?: 'forgotten' | 'on_this_day' | 'mix' | 'random'; // Strategy (default: 'mix')
  limit?: number;                                          // Number of notes (default: 3, max: 10)
}
```

**Trigger phrases** the agent recognises:

* "surprise me"
* "what did I save before?"
* "on this day"
* "rediscover something"
* "show me something old"
* "random note"

**Setting up the daily job:**

Tell the agent:

> *"Schedule a daily knowledge resurfacing at 9am."*

The agent will create a `resurface` schedule with cron `0 9 * * *`. You can customize it:

```
Schedule a daily resurface at 7am, on_this_day mode only.
Schedule a weekly knowledge rediscovery every Sunday at 10am, 5 notes.
```

**Scheduler config options:**

| Key     | Type                                                | Default | Description                           |
| ------- | --------------------------------------------------- | ------- | ------------------------------------- |
| `mode`  | `'forgotten' \| 'on_this_day' \| 'random' \| 'mix'` | `'mix'` | Resurfacing strategy                  |
| `limit` | number                                              | `3`     | Number of notes to broadcast (max 10) |

### PDF Plugin

The PDF plugin (`@echos/plugin-pdf`) provides the `save_pdf` tool for extracting and saving text from PDF documents.

**Features:**

* Download PDFs from public http(s) URLs and extract text via `pdf-parse` (pure JS, no native deps)
* Preserves page count in the note header/body; stores author and source URL in frontmatter metadata (when available)
* Enforces a 10 MiB PDF download size limit; larger binaries are rejected with a clear error
* Truncates oversized content gracefully (max 500 000 chars), appending `[content truncated due to size limit]` and marking the "Extracted characters" field as `(truncated)`
* Fails clearly on password-protected or corrupt PDFs
* Optional AI categorization for automatic tagging
* URL validation via `validateUrl()` (SSRF protection: only public http(s) URLs; private/localhost/internal hosts are blocked)

**Tool: save\_pdf**

```typescript theme={null}
{
  url: string;              // PDF URL (required)
  title?: string;           // Override title (defaults to PDF metadata or filename)
  tags?: string[];          // Tags to apply
  categorize?: boolean;     // Use AI to categorize (default: true)
}
```

**Metadata in saved note:**

* `**Source:**` — source URL
* `**Pages:**` — page count
* `**Extracted characters:**` — character count (with truncation notice if applicable)
* `**Author:**` — if present in PDF metadata

### Audio / Podcast Plugin

The audio plugin (`@echos/plugin-audio`) provides the `save_audio` tool for transcribing podcast episodes and audio files via OpenAI Whisper and saving the transcript as a searchable knowledge note.

**Requires `OPENAI_API_KEY`** — Whisper is an OpenAI API service. The plugin fails gracefully with a clear message if the key is absent.

**Features:**

* Downloads audio from public http(s) URLs and transcribes via `whisper-1`
* Supports: `.mp3`, `.wav`, `.m4a`, `.ogg`, `.webm`, `.mp4`, `.flac`
* Files > 25 MB are split into 24 MB byte-range chunks and transcribed sequentially — no ffmpeg required
* Probes file size with a HEAD request, then falls back to a `Range: bytes=0-0` probe if `Content-Length` is absent
* Streams downloads with a hard 25 MB cap to prevent unbounded memory usage
* Saves notes with `inputSource: 'voice'` and `type: 'note'`
* Optional AI categorization via Anthropic
* Respects `WHISPER_LANGUAGE` config for language hints

**Tool: save\_audio**

```typescript theme={null}
{
  url: string;              // Audio file URL (required)
  title?: string;           // Override title (defaults to filename from URL)
  tags?: string[];          // Tags to apply
  categorize?: boolean;     // Use AI to categorize (default: true)
}
```

**Metadata in saved note:**

* `**Source:**` — source URL
* `**Format:**` — file extension (e.g. `MP3`)
* `**File size:**` — human-readable size (KB/MB)
* `**Duration estimate:**` — estimated from file size and format bitrate
* `**Transcript length:**` — character count

**Chunking for large files:**

Files exceeding the Whisper 25 MB limit are automatically chunked:

1. A HEAD request probes `Content-Length`
2. If absent, a `Range: bytes=0-0` request reads the `Content-Range` total size
3. If the total exceeds 25 MB, the file is fetched in 24 MB byte-range slices
4. Each slice is transcribed separately; results are joined with `\n\n`
5. If the server does not honour `Range` headers (returns 200 instead of 206), an informative error is returned

**Example prompts:**

* *"Save this podcast episode: \[URL]"*
* *"Transcribe and save this interview recording"*
* *"Save the audio from this conference talk: \[URL]"*

### Journal Plugin

The journal plugin (`@echos/plugin-journal`) provides a dedicated journaling experience with two agent tools and an optional daily prompt job.

**Features:**

* Dedicated `journal` tool for creating journal/diary entries (replaces `create_note(type="journal")`)
* AI-powered `reflect` tool that synthesizes journal entries over a time period
* Optional `journal_prompt` scheduled job for daily journaling nudges via Telegram

**Tool: journal**

```typescript theme={null}
{
  title: string;                          // Entry title (required)
  content: string;                        // Markdown content (required)
  tags?: string[];                        // Tags for categorization
  category?: string;                      // Category (e.g. "reflection", "work", "health")
  inputSource?: 'text' | 'voice';         // How the entry was captured (default: 'text')
}
```

All journal entries are stored with `type: 'journal'` and `status: 'read'`. After creating, the agent always calls `categorize_note` for automatic tagging.

**Tool: reflect**

```typescript theme={null}
{
  period?: 'week' | 'month' | 'custom';   // Time period (default: 'week')
  dateFrom?: string;                       // Start date for custom range (ISO 8601)
  dateTo?: string;                         // End date for custom range (ISO 8601)
}
```

The reflect tool:

* Fetches journal entries within the date range (up to 50 entries)
* Spawns a sub-agent to synthesize patterns, mood trends, key themes, and insights
* Returns a warm, structured reflection with actionable suggestions
* Validates date ranges (max 365 days lookback)

**Trigger phrases** the agent recognises:

* "reflect on my journal"
* "weekly journal review"
* "how has my week been?"
* "mood summary"
* "look back at my journaling"

**Setting up the daily prompt:**

Tell the agent:

> *"Schedule a daily journal prompt at 9pm."*

The agent will create a `journal_prompt` schedule with cron `0 21 * * *`.

**Scheduler config options:**

| Key      | Type   | Default                   | Description                         |
| -------- | ------ | ------------------------- | ----------------------------------- |
| `prompt` | string | Built-in journaling nudge | Custom prompt text (max 1000 chars) |

### RSS Feed Plugin

The RSS plugin (`@echos/plugin-rss`) provides the `manage_feeds` tool for subscribing to RSS and Atom feeds, with automatic background polling every 4 hours.

**Features:**

* Subscribe to any RSS 2.0 or Atom feed via URL
* Automatic deduplication — each article is saved at most once, even if a manual refresh and a scheduled poll run concurrently (atomic guid claim before processing)
* Full article extraction via `@echos/plugin-article` (Readability) — not just the feed summary
* AI categorization applied automatically when `ANTHROPIC_API_KEY` or `LLM_API_KEY` is configured
* Per-feed tags: all articles from a feed inherit its configured tags plus the `rss` tag
* Background polling every 4 hours via a self-registering BullMQ schedule (`rss-poll`)
* Plugin-specific SQLite database at `{DB_PATH}/rss.db` — separate from `echos.db`

**Tool: manage\_feeds**

```typescript theme={null}
{
  action: 'add' | 'list' | 'remove' | 'refresh';
  url?: string;    // Feed URL (required for add/remove/refresh of a specific feed)
  name?: string;   // Display name (used with add; defaults to feed title)
  tags?: string[]; // Tags applied to all articles from this feed (used with add)
}
```

**Actions:**

| Action    | Description                                                                                    |
| --------- | ---------------------------------------------------------------------------------------------- |
| `add`     | Subscribe to a feed. Validates the URL, fetches the feed to confirm it parses, then stores it. |
| `list`    | List all subscribed feeds with last-checked time and saved article count.                      |
| `remove`  | Unsubscribe from a feed. Previously saved articles are retained.                               |
| `refresh` | Immediately fetch and save new articles. Omit `url` to refresh all feeds.                      |

**Example prompts:**

* *"Subscribe to this RSS feed: [https://example.com/feed.xml](https://example.com/feed.xml), tag it as 'news'"*
* *"List my RSS feed subscriptions"*
* *"Unsubscribe from [https://example.com/feed.xml](https://example.com/feed.xml)"*
* *"Refresh all my RSS feeds now"*

**Automatic polling:**

On first startup, the plugin registers a default schedule (`rss-poll`) with cron `0 */4 * * *` (every 4 hours). The schedule is stored in SQLite and picked up by the `ScheduleManager` — no manual setup required.

To change the poll frequency, update the schedule via the agent:

> *"Change the RSS poll schedule to run every 2 hours."*

**Storage:**

* Each feed entry is stored as a note with `type: article`, `inputSource: url`, and `sourceUrl` pointing to the original article
* Feed-specific data (subscriptions, seen guids) lives in `{DB_PATH}/rss.db` and is managed entirely by the plugin
* Deleting a feed subscription cascades to its entry records in `rss.db` — no orphan rows
