Drop a File, Get a Feature: ArgoBox's Module System

Drop a File, Get a Feature

The Problem

ArgoBox has a lot of features. Email, RAG chat, file locker, credential audit, monitoring status, build swarm dashboard, network scanner. Each one needs admin pages, API routes, nav entries, documentation, and configuration.

When everything was hardcoded, adding a feature meant editing five files. The admin nav config. The page layout. The API route manifest. The docs sidebar. The module registry. Touch one, forget another, and you’ve got a page that exists but isn’t in the nav, or a nav item that links to nothing.

I wanted: drop a file, get a feature. Remove the file, feature disappears. No runtime scanning, no dependency injection framework, no config database. Just files.

The Starlight Inspiration

Astro’s Starlight documentation framework has this pattern where plugins declare their routes, components, and configuration as objects, and the framework wires everything at build time. No manual route registration. No imperative “add this page to the sidebar” calls. You describe what exists; the framework discovers it.

I borrowed that idea but adapted it for admin modules instead of documentation pages.

How It Works

The Module File

Each feature lives in src/config/modules/. Here’s the file-locker module:

// src/config/modules/file-locker.ts
import type { ModuleManifest } from '../module-manifest';

const manifest: ModuleManifest = {
  id: 'file-locker',
  label: 'File Locker',
  icon: 'lock',
  group: 'tools',
  pages: ['/admin/file-locker'],
  apiRoutes: ['/api/admin/file-locker'],
  docPath: '/docs/modules/file-locker',
  requiredEnvVars: ['R2_BUCKET'],
  description: 'Password-protected file storage with R2 backend'
};

export default manifest;

That’s the entire registration. The module declares what it provides — pages, API routes, docs, required environment variables — and the system handles the rest.

Build-Time Discovery

The magic is one line in the admin nav config:

// src/config/admin-nav.ts
const moduleGlobs = import.meta.glob<{ default: ModuleManifest }>(
  './modules/*.ts',
  { eager: true }
);

Astro’s import.meta.glob runs at build time. It finds every .ts file in the modules/ directory and imports it. No filesystem scanning at runtime. No dynamic imports. The bundler resolves everything during the build.

The rest iterates over discovered modules and generates nav items:

const moduleNavItems = Object.values(moduleGlobs)
  .map(mod => mod.default)
  .filter(manifest => manifest.pages?.length)
  .map(manifest => ({
    id: manifest.id,
    label: manifest.label,
    icon: manifest.icon,
    href: manifest.pages[0],
    group: manifest.group
  }));

Core nav items (dashboard, settings) stay hardcoded. Module items merge in, deduplicated by id. The admin sidebar builds itself.

Zero Runtime Coupling

The important part: modules don’t import each other. The file-locker module doesn’t know the email module exists. The RAG chat module doesn’t know about the credential audit. They all export a manifest. The discovery system reads the manifests. That’s the only connection.

Add a module: Create src/config/modules/my-feature.ts with a valid manifest. Next build, it appears in the admin nav.

Remove a module: Delete the module file and its associated pages/API routes. Next build, it disappears.

Disable temporarily: Rename the file to my-feature.ts.disabled. The glob doesn’t match it. Feature gone from the nav, pages still exist on disk for later.

The ModuleManifest Interface

interface ModuleManifest {
  id: string;                 // Unique identifier
  label: string;              // Display name in nav
  icon: string;               // Icon name
  group: string;              // Nav group (tools, ai, monitoring)
  pages?: string[];           // Astro page paths
  apiRoutes?: string[];       // API endpoint paths
  docPath?: string;           // Documentation page
  configFiles?: string[];     // Config files this module uses
  dependencies?: string[];    // Other module IDs required
  requiredEnvVars?: string[]; // Environment variables needed
  description?: string;       // Human-readable description
}

Not every field is actively used yet. dependencies and configFiles are there for future module validation — “warn if email module is enabled but its env vars aren’t set.” The manifest is descriptive, not prescriptive. Unused fields document intent.

What This Replaced

Before the module system, the admin nav was a 200-line hardcoded array:

const adminNav = [
  { id: 'dashboard', label: 'Dashboard', href: '/admin', icon: 'home' },
  { id: 'email', label: 'Email', href: '/admin/email', icon: 'mail' },
  { id: 'file-locker', label: 'File Locker', href: '/admin/file-locker', icon: 'lock' },
  // ... 20 more entries
];

Adding a feature meant editing this array. And the docs sidebar. And probably the route permissions in the middleware. Three places to update, three places to forget.

Now I update one manifest file. The nav, the docs link, the module registry — all derived from that one source.

The Trade-Off

This pattern works because ArgoBox is a personal platform where I’m the only developer. The modules are loosely coupled because they don’t need to be tightly coupled — they’re independent features that happen to live in the same admin panel.

For a team project with modules that depend on each other, you’d want something more formal. Dependency resolution, load ordering, module lifecycle hooks. Full DI containers.

I don’t need that. I need “drop file, get nav item.” So that’s what I built.

The Current Modules

src/config/modules/
├── ai-chat.ts          # Argonaut RAG chat
├── api-keys.ts         # API key management
├── build-swarm.ts      # Build swarm dashboard
├── credential-audit.ts # Credential health checks
├── email.ts            # EdgeMail integration
├── file-locker.ts      # R2 file storage
├── monitoring.ts       # Uptime Kuma status
├── net-scanner.ts      # Network discovery
└── rag-admin.ts        # RAG ingestion & search

Nine modules. Each one is a single file declaring what it provides. The admin panel, the documentation sidebar, and the module registry all build themselves from these declarations.

Adding a tenth takes about 2 minutes. Most of that is deciding what icon to use.