Skip to main content

Documentation Index

Fetch the complete documentation index at: https://grantmaster.dev/llms.txt

Use this file to discover all available pages before exploring further.

Extension Development Guide

This guide covers everything you need to build, test, and ship a GrantMaster extension.

Quick Start

# Scaffold a new extension
npx tsx scripts/create-extension.ts

# Start development
npm run dev
Navigate to /<your-extension>/dashboard to see your scaffolded page.

Architecture Overview

Extensions are self-contained feature modules that register with the platform at activation time and cleanly deregister on deactivation. Each extension declares its capabilities in a manifest and provides components, services, and hooks in a standard directory structure.

Directory Structure

src/extensions/<id>/
├── manifest.ts                   # Extension manifest (required)
├── index.ts                      # Barrel export (required)
├── types.ts                      # Extension-local type definitions
├── components/
│   ├── <Name>Layout.tsx          # Root route layout
│   ├── pages/                    # Route page components
│   ├── widgets/                  # Dashboard widget components
│   └── shared/                   # Reusable extension components
├── context/
│   └── <Name>Context.tsx         # Provider + hook
├── handlers/                     # Event handlers and agent tool handlers
├── services/                     # Firestore service classes
├── hooks/                        # Custom React hooks (including lifecycle hooks)
├── schemas/                      # Zod validation schemas
└── utils/                        # Helper functions

Lifecycle

AVAILABLE ──install()──> TRIALING / ACTIVE
TRIALING  ──activate()──> ACTIVE
ACTIVE    <──suspend()/reactivate()──> SUSPENDED
ACTIVE    ──deactivate()──> INACTIVE
ACTIVE/INACTIVE ──uninstall()──> AVAILABLE
On activate: validate dependencies → register contributions → call lifecycle.onActivate On deactivate: call lifecycle.onDeactivate → unregister contributions → clean up subscriptions

Manifest Reference

The manifest is the single source of truth for your extension’s metadata, routing, and contribution declarations.
// src/extensions/<id>/manifest.ts
import type { ModuleManifest } from '@/features/extensions/contracts';
import { ModuleCategory } from '@/features/extensions/contracts';

export const manifest: ModuleManifest = {
  // ── Schema Version ──
  manifestVersion: 2,              // Manifest format version (1 = legacy, 2 = current)

  // ── Identity ──
  id: 'grant-calendar',           // Unique kebab-case ID
  name: 'Grant Calendar',         // Display name
  description: 'Track grant deadlines and milestones',
  icon: 'Calendar',               // Lucide icon name
  category: ModuleCategory.GRANTS,
  version: '1.0.0',               // Semver
  author: 'GrantMaster',          // Extension author name

  // ── Compatibility ──
  compatibility: {
    coreApiVersion: '^1.0.0',     // Semver range for required core API version
  },

  // ── Access Control ──
  minimumTier: undefined,          // Lowest tier that CAN install (undefined = any)
  includedInTiers: [],             // Tiers where module is auto-available (no extra charge)
  features: [],                    // Feature flag names
  permissions: [],                 // Permission string names

  // ── Data ──
  dataCollections: [
    { name: 'grantCalendarEvents', label: 'Calendar Events', uninstallPolicy: 'archive' },
    { name: 'grantCalendarSettings', label: 'Calendar Settings', uninstallPolicy: 'delete' },
  ],

  // ── Routing ──
  basePath: '/grant-calendar',
  codeDirectory: 'grant-calendar', // Directory name under src/extensions/ (defaults to `id`)
  layoutComponent: './components/GrantCalendarLayout',
  routes: [
    { path: '', redirect: '/grant-calendar/calendar' },
    { path: 'calendar', component: './components/pages/CalendarViewPage' },
    { path: 'settings', component: './components/pages/CalendarSettingsPage' },
  ],

  // ── Sidebar ──
  sidebarEntry: {
    label: 'Grant Calendar',
    icon: 'Calendar',
    to: '/grant-calendar',
    permission: 'VIEW_GRANT_CALENDAR',
    position: 'main',             // 'main' = core nav, 'bottom' = utility section
    sortOrder: 25,
  },

  // ── Billing (optional) ──
  pricing: {
    model: 'flat_monthly',
    monthlyPrice: 990,            // Price in cents (EUR)
    yearlyPrice: 9900,
    trialDays: 14,
    currency: 'EUR',
    stripePriceId: 'price_xxx',
    stripeYearlyPriceId: 'price_yyy',
  },

  // ── Dependencies ──
  requiredModules: [],             // IDs of required extensions

  // ── Contributions (see below) ──
  contributions: { /* ... */ },

  // ── Lifecycle Hooks (see below) ──
  lifecycle: { /* ... */ },

  // ── Migrations (see below) ──
  migrations: [],
};

Contribution Types

Extensions can contribute to 5 extension points. All contributions are declared in manifest.contributions and automatically registered/unregistered during activation/deactivation.

1. Widgets

Dashboard widgets that appear in the widget grid.
contributions: {
  widgets: [
    {
      id: 'upcoming-deadlines',
      title: 'Upcoming Deadlines',
      description: 'Shows the next 5 grant deadlines and reporting dates',
      category: 'grants',
      componentPath: './components/widgets/UpcomingDeadlinesWidget',
      icon: 'CalendarClock',
      size: 'standard',                      // 'compact' | 'standard' | 'chart' | 'large'
      requiredPermissions: ['VIEW_CALENDAR'],
    },
  ],
}

2. Commands

Command palette actions accessible via Cmd+K.
contributions: {
  commands: [
    {
      id: 'open-calendar',
      label: 'Open Grant Calendar',
      description: 'Navigate to the grant calendar view',
      icon: 'Calendar',
      group: 'navigation',                // 'navigation' | 'create' | 'context' | 'ai'
      action: '/grant-calendar/calendar', // Navigation path or callback identifier
      keywords: ['calendar', 'deadlines', 'schedule'],
      permission: 'VIEW_GRANT_CALENDAR',
      routeContext: '/grant-calendar',    // For relevance boosting
      priority: 10,                       // Sort priority within group (lower = higher)
      section: 'Grant Calendar',          // Visual grouping label in palette
    },
  ],
}

3. Event Handlers

Declare event subscriptions that the platform manages. Handler functions are resolved at activation time from the declared path. Each handler can subscribe to multiple event types.
contributions: {
  eventHandlers: [
    {
      eventTypes: ['GRANT_REPORT_DUE', 'GRANT_WON'],
      handlerPath: './handlers/onGrantEvent',
      description: 'Auto-creates calendar events when grants are won or reports become due',
    },
  ],
}
Handler module (handlers/onGrantEvent.ts):
import type { SystemEvent } from '@/shared/extension-api';

export default async function onGrantEvent(event: SystemEvent) {
  // Handle the event — event.type distinguishes between GRANT_REPORT_DUE and GRANT_WON
}

4. Settings Panels

Panels rendered in the Settings page when the extension is active.
contributions: {
  settingsPanels: [
    {
      id: 'calendar-settings',
      label: 'Calendar',
      icon: 'Calendar',
      componentPath: './components/pages/CalendarSettingsPanel',
      sortOrder: 50,
    },
  ],
}

5. Agent Tools

AI agent tools that become available when the extension is active.
contributions: {
  agentTools: [
    {
      name: 'check_upcoming_deadlines',
      description: 'Check upcoming grant deadlines and reporting dates within a given number of days',
      handlerPath: './handlers/checkUpcomingDeadlines',
      requiredPermissions: ['VIEW_GRANT_CALENDAR'],
      creditCost: 1,              // Credit cost per invocation
    },
  ],
}

Lifecycle Hooks

Lifecycle hooks let your extension execute code at key transitions. Declare them as relative paths (prefixed with ./) in your manifest:
lifecycle: {
  onActivate: './hooks/onActivate',       // Called after contributions registered
  onDeactivate: './hooks/onDeactivate',    // Called before contributions unregistered
  onTenantSwitch: './hooks/onTenantSwitch', // Called when user switches organization
},
Each hook module should export a default function:
// hooks/onActivate.ts
export default async function onActivate(ctx: { extensionId: string }) {
  // Seed default settings, warm caches, etc.
}
// hooks/onTenantSwitch.ts
export default async function onTenantSwitch(ctx: {
  extensionId: string;
  organizationId: string;
}) {
  // Clear caches, reload org-specific data
}

Extension API

Extensions should import from @/shared/extension-api rather than internal modules. This facade provides a stable contract.

Available Imports

import {
  // Version & Compatibility
  CORE_API_VERSION,
  isCompatible,
  parseSemver,

  // Extension Base Service
  ExtensionBaseService,

  // Scoped EventBus
  createExtensionEventBus,

  // Widget Contributions
  registerExtensionWidgets,
  unregisterExtensionWidgets,
  getExtensionWidgetIds,
  getExtensionWidgetCount,
  clearAllExtensionWidgets,

  // Command Palette Contributions
  registerExtensionActions,
  unregisterExtensionActions,
  getExtensionActionIds,
  getExtensionActionCount,
  clearAllExtensionActions,

  // Enums (Auth & RBAC)
  Permission,
  SystemRole,

  // Enums (Events)
  SystemEventType,
  EventSeverity,

  // Enums (Extensions)
  ModuleStatus,
  ModuleInstallStatus,
  ModulePricingModel,
  ModuleCategory,

  // Enums (Widgets)
  WidgetCategory,
  WidgetSize,
  WidgetHeight,
} from '@/shared/extension-api';

// Types
import type {
  // Events
  SystemEvent,
  EventBusStats,
  EventHandler,
  IEventBus,
  ILogger,
  ITimeProvider,
  IFirestoreClient,
  LogContext,

  // Extension types
  ExtensionEventBus,
  ExtensionWidgetContribution,
  ExtensionCommandContribution,
  ModuleManifest,
  ModuleDefinition,
  ModuleInstallation,
  ModuleRouteEntry,
  ModuleSidebarEntry,
  ModuleDataCollection,
  ModulePricing,
  ResolvedModule,
  ExtensionContributions,
  ExtensionEventHandlerDeclaration,
  ExtensionSettingsPanelContribution,
  ExtensionAgentToolContribution,
  ExtensionLifecycleHooks,
  ExtensionMigration,

  // Widgets & Commands
  WidgetDefinition,
  CommandAction,
  CommandGroup,

  // Services
  ServiceResult,
  AgentOperationContext,
  ServiceOperationOptions,

  // Feature flags
  Feature,
} from '@/shared/extension-api';

Scoped Event Bus

Use createExtensionEventBus() instead of the raw eventBus singleton. This ensures subscriptions are tracked and cleaned up on deactivation:
import { createExtensionEventBus, SystemEventType } from '@/shared/extension-api';

const bus = createExtensionEventBus('grant-calendar');

// Subscribe (tracked for cleanup)
bus.on(SystemEventType.GRANT_REPORT_DUE, async (event) => {
  // Handle event
});

// Emit (tagged with extensionId)
await bus.emit({
  type: SystemEventType.GRANT_CALENDAR_EVENT_CREATED,
  organizationId: 'org-123',
  userId: 'user-456',
  severity: EventSeverity.INFO,
  payload: { eventId: 'evt-789' },
});

Extension Base Service

Extend ExtensionBaseService<T> for Firestore operations. It enforces organization scoping and auto-includes extensionId in audit logs. The class is generic and uses abstract protected fields instead of constructor arguments:
import { ExtensionBaseService } from '@/shared/extension-api';
import type { CalendarEvent } from '../types';

export class CalendarEventService extends ExtensionBaseService<CalendarEvent> {
  protected collectionName = 'grantCalendarEvents';
  protected serviceName = 'CalendarEventService';
  protected extensionId = 'grant-calendar';

  async getEvents(orgId: string) {
    return this.withErrorBoundary(async () => {
      // Your Firestore logic here
    });
  }
}

Data Migrations

When upgrading an extension’s data schema, declare migrations in the manifest:
migrations: [
  {
    fromVersion: '1.0.0',
    toVersion: '1.1.0',
    description: 'Add priority field to calendar events',
    migrationPath: 'migrations/v1_1_addPriority',
  },
],
Migration module:
// migrations/v1_1_addPriority.ts
export default async function migrate(ctx: {
  extensionId: string;
  organizationId: string;
  fromVersion: string;
  toVersion: string;
}) {
  // Run Firestore migration logic
}
Migrations run automatically during activation when the installed version is behind the manifest version. They execute in order (sorted by fromVersion). If a migration fails, the extension is suspended.

Dependency Validation

Declare dependencies on other extensions:
requiredModules: ['impact'],
The platform validates:
  • On install: All required extensions must be active
  • On deactivate: Cannot deactivate if other active extensions depend on you
  • On uninstall: Cannot uninstall if other installed extensions depend on you

Error Handling

Route Error Boundaries

Every extension’s route group is wrapped in an <ErrorBoundary>. If your extension crashes:
  1. The error is caught — the app shell remains functional
  2. The error is reported to ExtensionHealthService
  3. After 3 crashes in 5 minutes, the extension is auto-suspended
  4. An EXTENSION_AUTO_DISABLED event is emitted

Best Practices

  • Wrap risky operations in try/catch
  • Use ExtensionBaseService.withErrorBoundary() for service methods
  • Event handler failures are tracked — 5 consecutive throws auto-unsubscribes the handler
  • Widget crashes show a placeholder, not a broken dashboard

Testing

Use the test harness at src/tests/utils/extensionTestHarness.ts:
import {
  createTestManifest,
  createMockEventBus,
  mockExtensionActivation,
  assertContributionsRegistered,
  assertEventEmitted,
} from '@/tests/utils/extensionTestHarness';

describe('Grant Calendar Extension', () => {
  it('registers all contributions', () => {
    const manifest = createTestManifest({
      id: 'grant-calendar',
      contributions: {
        widgets: [{ /* widget def */ }],
        commands: [{ /* command def */ }],
      },
    });

    const result = mockExtensionActivation(manifest);
    assertContributionsRegistered(result, {
      widgets: 1,
      commands: 1,
    });
  });

  it('emits events on the scoped bus', async () => {
    const bus = createMockEventBus();
    bus.subscribeForExtension('grant-calendar', SystemEventType.GRANT_WON, async () => {});

    await bus.emit({
      type: SystemEventType.GRANT_WON,
      organizationId: 'org-1',
      userId: 'user-1',
      severity: EventSeverity.INFO,
      payload: {},
    });

    assertEventEmitted(bus, SystemEventType.GRANT_WON);
    expect(bus.getExtensionSubscriptionCount('grant-calendar')).toBe(1);
  });
});

Admin Monitoring

Extension health is visible in Configuration > Extensions (PlatformManagement). Admins can:
  • View active extensions and their contribution counts
  • See per-extension EventBus metrics (emit counts, error counts)
  • Monitor auto-disabled extensions and re-enable them
  • Check health records and error history

Registered Extensions

The following extensions are registered in MANIFEST_LOADERS (as of this writing):
Extension IDCategory
impactImpact measurement & M&E
grant-calendarGrant deadline tracking
donor-walletDonor management
virtual-giving-cardGiving cards
corporate-csr-hubCorporate social responsibility
board-portalBoard management
budget-forecasterBudget analytics
compliance-vaultCompliance document storage
event-fundraiserEvent-based fundraising
funder-crmFunder relationship management
grant-writerAI-assisted grant writing
volunteer-coordinatorVolunteer management

Checklist

Before shipping your extension:
  • Manifest has all required fields (id, name, version, basePath, routes)
  • Extension registered in src/shared/platform/ExtensionRegistry.ts MANIFEST_LOADERS
  • Layout component renders <Outlet /> for child routes
  • Imports use @/shared/extension-api (not @/core/*)
  • Event subscriptions use createExtensionEventBus() (not raw eventBus)
  • Services extend ExtensionBaseService (not BaseService)
  • Data collections declared with appropriate uninstallPolicy
  • Dependencies listed in requiredModules if applicable
  • Migrations declared for schema changes between versions
  • Tests written using extensionTestHarness
  • No TypeScript errors in extension files (npx tsc --noEmit)