Custom Provider Development Guide

November 12, 2025 ยท View on GitHub

Build your own resource providers

This guide explains how to create custom resource providers for Orchestr8, from basic implementation to production deployment.

Table of Contents

Provider Interface

All providers must implement the ResourceProvider interface:

interface ResourceProvider {
  // Metadata
  readonly name: string;
  enabled: boolean;
  readonly priority: number;

  // Lifecycle
  initialize(): Promise<void>;
  shutdown(): Promise<void>;

  // Resource Operations
  fetchIndex(): Promise<RemoteResourceIndex>;
  fetchResource(id: string, category: string): Promise<RemoteResource>;
  search(query: string, options?: SearchOptions): Promise<SearchResponse>;

  // Health & Monitoring
  healthCheck(): Promise<ProviderHealth>;
  getStats(): ProviderStats;
  resetStats(): void;
}

Creating a Provider

Step 1: Basic Structure

import { LRUCache } from "lru-cache";
import { Logger } from "../utils/logger.js";
import {
  ResourceProvider,
  RemoteResourceIndex,
  RemoteResource,
  SearchResponse,
  SearchOptions,
  ProviderHealth,
  ProviderStats,
  ProviderError,
} from "./types.js";

export interface CustomProviderConfig {
  enabled: boolean;
  apiUrl: string;
  apiKey?: string;
  cacheTTL?: number;
  timeout?: number;
}

export class CustomProvider implements ResourceProvider {
  readonly name = "custom";
  enabled: boolean;
  readonly priority = 20; // Lower priority than built-in providers

  private config: CustomProviderConfig;
  private logger: Logger;
  private cache: LRUCache<string, RemoteResource>;
  private stats: ProviderStats;

  constructor(config: CustomProviderConfig, logger: Logger) {
    this.config = config;
    this.logger = logger;
    this.enabled = config.enabled;

    // Initialize cache
    this.cache = new LRUCache({
      max: 500,
      ttl: config.cacheTTL || 3600000, // 1 hour default
    });

    // Initialize stats
    this.stats = {
      provider: this.name,
      totalRequests: 0,
      successfulRequests: 0,
      failedRequests: 0,
      cachedRequests: 0,
      resourcesFetched: 0,
      tokensFetched: 0,
      avgResponseTime: 0,
      cacheHitRate: 0,
      uptime: 1.0,
      statsResetAt: new Date(),
    };
  }

  async initialize(): Promise<void> {
    this.logger.info("Initializing CustomProvider");
    
    // Verify configuration
    if (!this.config.apiUrl) {
      throw new ProviderError(
        "API URL is required",
        this.name,
        "INVALID_CONFIG"
      );
    }

    // Test connectivity
    try {
      await this.testConnection();
      this.logger.info("CustomProvider initialized successfully");
    } catch (error) {
      this.logger.error("Failed to initialize CustomProvider", error);
      throw error;
    }
  }

  async shutdown(): Promise<void> {
    this.logger.info("Shutting down CustomProvider");
    this.cache.clear();
  }

  async fetchIndex(): Promise<RemoteResourceIndex> {
    // Implementation details below
    throw new Error("Not implemented");
  }

  async fetchResource(id: string, category: string): Promise<RemoteResource> {
    // Implementation details below
    throw new Error("Not implemented");
  }

  async search(query: string, options?: SearchOptions): Promise<SearchResponse> {
    // Implementation details below
    throw new Error("Not implemented");
  }

  async healthCheck(): Promise<ProviderHealth> {
    // Implementation details below
    throw new Error("Not implemented");
  }

  getStats(): ProviderStats {
    return { ...this.stats };
  }

  resetStats(): void {
    this.stats = {
      provider: this.name,
      totalRequests: 0,
      successfulRequests: 0,
      failedRequests: 0,
      cachedRequests: 0,
      resourcesFetched: 0,
      tokensFetched: 0,
      avgResponseTime: 0,
      cacheHitRate: 0,
      uptime: 1.0,
      statsResetAt: new Date(),
    };
  }

  private async testConnection(): Promise<void> {
    // Test API connectivity
  }
}

Step 2: Implement fetchIndex()

async fetchIndex(): Promise<RemoteResourceIndex> {
  this.logger.debug("Fetching resource index");
  this.stats.totalRequests++;
  const startTime = Date.now();

  try {
    // Fetch from your API
    const response = await fetch(`${this.config.apiUrl}/resources`, {
      headers: {
        "Authorization": `Bearer ${this.config.apiKey}`,
        "Content-Type": "application/json",
      },
      signal: AbortSignal.timeout(this.config.timeout || 30000),
    });

    if (!response.ok) {
      throw new ProviderError(
        `HTTP ${response.status}: ${response.statusText}`,
        this.name,
        "HTTP_ERROR",
        response.status
      );
    }

    const data = await response.json();

    // Transform to RemoteResourceIndex format
    const resources: RemoteResourceMetadata[] = data.resources.map(
      (item: any) => ({
        id: item.id,
        category: item.category as any,
        title: item.title || item.id,
        description: item.description || "",
        tags: item.tags || [],
        capabilities: item.capabilities || [],
        useWhen: item.useWhen || [],
        estimatedTokens: item.tokenCount || 0,
        source: this.name,
        sourceUri: `${this.config.apiUrl}/resources/${item.id}`,
      })
    );

    // Calculate statistics
    const byCategory: Record<string, number> = {};
    let totalTokens = 0;

    for (const resource of resources) {
      byCategory[resource.category] = (byCategory[resource.category] || 0) + 1;
      totalTokens += resource.estimatedTokens;
    }

    const index: RemoteResourceIndex = {
      provider: this.name,
      totalCount: resources.length,
      resources,
      version: data.version || new Date().toISOString(),
      timestamp: new Date(),
      categories: Object.keys(byCategory) as any[],
      stats: {
        byCategory,
        totalTokens,
        topTags: [], // Calculate if needed
      },
    };

    // Update stats
    this.stats.successfulRequests++;
    this.updateResponseTime(Date.now() - startTime);

    return index;
  } catch (error) {
    this.stats.failedRequests++;
    this.updateResponseTime(Date.now() - startTime);
    throw this.wrapError(error, "Failed to fetch index");
  }
}

Step 3: Implement fetchResource()

async fetchResource(id: string, category: string): Promise<RemoteResource> {
  this.logger.debug("Fetching resource", { id, category });
  this.stats.totalRequests++;
  const startTime = Date.now();

  // Check cache first
  const cacheKey = `${category}:${id}`;
  const cached = this.cache.get(cacheKey);
  if (cached) {
    this.stats.cachedRequests++;
    this.logger.debug("Cache hit", { id });
    return cached;
  }

  try {
    // Fetch from API
    const response = await fetch(
      `${this.config.apiUrl}/resources/${category}/${id}`,
      {
        headers: {
          "Authorization": `Bearer ${this.config.apiKey}`,
        },
        signal: AbortSignal.timeout(this.config.timeout || 30000),
      }
    );

    if (response.status === 404) {
      throw new ResourceNotFoundError(this.name, id, category);
    }

    if (!response.ok) {
      throw new ProviderError(
        `HTTP ${response.status}: ${response.statusText}`,
        this.name,
        "HTTP_ERROR",
        response.status
      );
    }

    const data = await response.json();

    // Transform to RemoteResource format
    const resource: RemoteResource = {
      id: data.id,
      category: data.category as any,
      title: data.title || data.id,
      description: data.description || "",
      tags: data.tags || [],
      capabilities: data.capabilities || [],
      useWhen: data.useWhen || [],
      estimatedTokens: data.tokenCount || 0,
      source: this.name,
      sourceUri: `${this.config.apiUrl}/resources/${id}`,
      content: data.content,
      dependencies: data.dependencies,
      related: data.related,
    };

    // Cache it
    this.cache.set(cacheKey, resource);

    // Update stats
    this.stats.successfulRequests++;
    this.stats.resourcesFetched++;
    this.stats.tokensFetched += resource.estimatedTokens;
    this.updateResponseTime(Date.now() - startTime);

    return resource;
  } catch (error) {
    if (error instanceof ResourceNotFoundError) {
      throw error; // Don't count as failed request
    }

    this.stats.failedRequests++;
    this.updateResponseTime(Date.now() - startTime);
    throw this.wrapError(error, `Failed to fetch resource ${id}`);
  }
}
async search(
  query: string,
  options?: SearchOptions
): Promise<SearchResponse> {
  this.logger.debug("Searching resources", { query, options });
  this.stats.totalRequests++;
  const startTime = Date.now();

  try {
    // Build query parameters
    const params = new URLSearchParams({
      q: query,
      maxResults: (options?.maxResults || 50).toString(),
      minScore: (options?.minScore || 0).toString(),
    });

    if (options?.categories) {
      params.append("categories", options.categories.join(","));
    }

    // Fetch from API
    const response = await fetch(
      `${this.config.apiUrl}/search?${params}`,
      {
        headers: {
          "Authorization": `Bearer ${this.config.apiKey}`,
        },
        signal: AbortSignal.timeout(this.config.timeout || 30000),
      }
    );

    if (!response.ok) {
      throw new ProviderError(
        `Search failed: ${response.statusText}`,
        this.name,
        "SEARCH_FAILED",
        response.status
      );
    }

    const data = await response.json();

    // Transform results
    const results: SearchResult[] = data.results.map((item: any) => ({
      resource: {
        id: item.id,
        category: item.category,
        tags: item.tags || [],
        capabilities: item.capabilities || [],
        useWhen: item.useWhen || [],
        estimatedTokens: item.tokenCount || 0,
        content: "", // Not loaded in search results
      },
      score: item.score || 0,
      matchReason: item.matchReason || [],
    }));

    // Update stats
    this.stats.successfulRequests++;
    this.updateResponseTime(Date.now() - startTime);

    return {
      results,
      totalMatches: data.totalMatches || results.length,
      query,
      searchTime: Date.now() - startTime,
    };
  } catch (error) {
    this.stats.failedRequests++;
    this.updateResponseTime(Date.now() - startTime);
    throw this.wrapError(error, "Search failed");
  }
}

Step 5: Implement healthCheck()

async healthCheck(): Promise<ProviderHealth> {
  const startTime = Date.now();

  try {
    // Ping health endpoint
    const response = await fetch(`${this.config.apiUrl}/health`, {
      headers: {
        "Authorization": `Bearer ${this.config.apiKey}`,
      },
      signal: AbortSignal.timeout(5000), // Short timeout for health check
    });

    const responseTime = Date.now() - startTime;
    const reachable = response.ok;

    // Determine status
    let status: ProviderHealth["status"];
    const successRate = this.calculateSuccessRate();

    if (!reachable) {
      status = "unhealthy";
    } else if (successRate < 0.5) {
      status = "unhealthy";
    } else if (successRate < 0.9 || responseTime > 5000) {
      status = "degraded";
    } else {
      status = "healthy";
    }

    return {
      provider: this.name,
      status,
      lastCheck: new Date(),
      responseTime,
      reachable,
      authenticated: !!this.config.apiKey,
      metrics: {
        successRate,
        avgResponseTime: this.stats.avgResponseTime,
        consecutiveFailures: 0,
      },
    };
  } catch (error) {
    return {
      provider: this.name,
      status: "unhealthy",
      lastCheck: new Date(),
      responseTime: Date.now() - startTime,
      reachable: false,
      authenticated: false,
      error: (error as Error).message,
    };
  }
}

private calculateSuccessRate(): number {
  const total = this.stats.successfulRequests + this.stats.failedRequests;
  return total > 0 ? this.stats.successfulRequests / total : 1.0;
}

private updateResponseTime(time: number): void {
  const totalRequests = this.stats.totalRequests;
  this.stats.avgResponseTime =
    (this.stats.avgResponseTime * (totalRequests - 1) + time) / totalRequests;
}

private wrapError(error: unknown, message: string): ProviderError {
  if (error instanceof ProviderError) {
    return error;
  }
  return new ProviderError(
    message,
    this.name,
    "UNKNOWN_ERROR",
    undefined,
    error as Error
  );
}

Testing

Unit Tests

import { describe, it, expect, beforeEach } from "vitest";
import { CustomProvider } from "./custom.js";
import { Logger } from "../utils/logger.js";

describe("CustomProvider", () => {
  let provider: CustomProvider;
  let logger: Logger;

  beforeEach(() => {
    logger = new Logger("test");
    provider = new CustomProvider(
      {
        enabled: true,
        apiUrl: "https://api.example.com",
        apiKey: "test-key",
      },
      logger
    );
  });

  it("should initialize successfully", async () => {
    await expect(provider.initialize()).resolves.not.toThrow();
  });

  it("should fetch index", async () => {
    const index = await provider.fetchIndex();
    expect(index.provider).toBe("custom");
    expect(Array.isArray(index.resources)).toBe(true);
  });

  it("should fetch resource", async () => {
    const resource = await provider.fetchResource("test-agent", "agent");
    expect(resource.id).toBe("test-agent");
    expect(resource.content).toBeDefined();
  });

  it("should search resources", async () => {
    const results = await provider.search("test query");
    expect(Array.isArray(results.results)).toBe(true);
    expect(results.query).toBe("test query");
  });

  it("should handle health check", async () => {
    const health = await provider.healthCheck();
    expect(health.provider).toBe("custom");
    expect(health.status).toMatch(/healthy|degraded|unhealthy/);
  });

  it("should track statistics", () => {
    const stats = provider.getStats();
    expect(stats.provider).toBe("custom");
    expect(typeof stats.totalRequests).toBe("number");
  });
});

Integration Tests

describe("CustomProvider Integration", () => {
  let registry: ProviderRegistry;
  let provider: CustomProvider;

  beforeEach(async () => {
    registry = new ProviderRegistry();
    provider = new CustomProvider(config, logger);
    await registry.register(provider);
  });

  it("should integrate with registry", async () => {
    const providers = registry.getProviders();
    expect(providers).toContain(provider);
  });

  it("should appear in multi-provider search", async () => {
    const results = await registry.searchAll("test query");
    expect(results.results.length).toBeGreaterThan(0);
  });

  it("should fallback to other providers on failure", async () => {
    provider.enabled = false;
    const resource = await registry.fetchResourceAny("test-id", "agent");
    expect(resource).toBeDefined(); // Should come from another provider
  });
});

Registration

Add to Configuration Schema

// In config/schema.ts
const customProviderSchema = z.object({
  enabled: z.boolean().default(false),
  apiUrl: z.string().url(),
  apiKey: z.string().optional(),
  cacheTTL: z.number().int().positive().default(3600000),
  timeout: z.number().int().positive().default(30000),
});

const resourceProvidersSchema = z.object({
  aitmpl: aitmplProviderSchema.default({}),
  github: githubProviderSchema.default({}),
  custom: customProviderSchema.default({}), // Add here
});

Register in ResourceLoader

// In loaders/resourceLoader.ts
async initializeProviders(): Promise<void> {
  // ... existing providers ...

  // Register custom provider
  const customConfig = this.providerConfigManager.getCustomConfig();
  if (customConfig.enabled) {
    this.logger.info("Initializing CustomProvider");
    try {
      const customProvider = new CustomProvider(customConfig, this.logger);
      await this.registry.register(customProvider);
      this.logger.info("CustomProvider registered successfully");
    } catch (error) {
      this.logger.error("Failed to initialize CustomProvider:", error);
    }
  }
}

Best Practices

1. Error Handling

// Always wrap errors in ProviderError
try {
  const data = await fetchData();
  return data;
} catch (error) {
  throw new ProviderError(
    "Operation failed",
    this.name,
    "OPERATION_FAILED",
    undefined,
    error as Error
  );
}

2. Caching

// Cache aggressively, invalidate carefully
const cacheKey = `${category}:${id}`;

// Check cache
const cached = this.cache.get(cacheKey);
if (cached) {
  return cached;
}

// Fetch and cache
const data = await fetch();
this.cache.set(cacheKey, data);
return data;

3. Statistics Tracking

// Track all metrics
async operation() {
  this.stats.totalRequests++;
  const startTime = Date.now();
  
  try {
    const result = await doWork();
    this.stats.successfulRequests++;
    return result;
  } catch (error) {
    this.stats.failedRequests++;
    throw error;
  } finally {
    this.updateResponseTime(Date.now() - startTime);
  }
}

4. Health Monitoring

// Comprehensive health checks
async healthCheck(): ProviderHealth {
  // Check connectivity
  const reachable = await this.ping();
  
  // Check authentication
  const authenticated = await this.verifyAuth();
  
  // Calculate metrics
  const successRate = this.calculateSuccessRate();
  
  // Determine status
  const status = this.determineStatus(reachable, authenticated, successRate);
  
  return { provider: this.name, status, ... };
}

Example: S3Provider

Complete example of an S3-based provider:

import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3";

export class S3Provider implements ResourceProvider {
  readonly name = "s3";
  enabled = true;
  readonly priority = 25;

  private s3Client: S3Client;
  private bucketName: string;
  private cache: LRUCache<string, RemoteResource>;
  
  constructor(config: S3ProviderConfig, logger: Logger) {
    this.bucketName = config.bucketName;
    this.s3Client = new S3Client({
      region: config.region,
      credentials: {
        accessKeyId: config.accessKeyId,
        secretAccessKey: config.secretAccessKey,
      },
    });
    
    this.cache = new LRUCache({ max: 500, ttl: 3600000 });
  }

  async initialize(): Promise<void> {
    // Verify bucket access
    try {
      await this.s3Client.send(new ListObjectsV2Command({
        Bucket: this.bucketName,
        MaxKeys: 1,
      }));
    } catch (error) {
      throw new ProviderError(
        `Cannot access S3 bucket: ${this.bucketName}`,
        this.name,
        "BUCKET_ACCESS_DENIED",
        undefined,
        error as Error
      );
    }
  }

  async fetchIndex(): Promise<RemoteResourceIndex> {
    // List all objects in bucket
    const command = new ListObjectsV2Command({
      Bucket: this.bucketName,
    });
    
    const response = await this.s3Client.send(command);
    const resources: RemoteResourceMetadata[] = [];
    
    for (const object of response.Contents || []) {
      if (object.Key?.endsWith(".md")) {
        // Parse key to extract category and id
        // s3://bucket/agents/typescript-developer.md
        const parts = object.Key.split("/");
        const category = parts[0];
        const id = parts[1].replace(".md", "");
        
        resources.push({
          id,
          category: category as any,
          title: id,
          description: "",
          tags: [],
          capabilities: [],
          useWhen: [],
          estimatedTokens: Math.ceil((object.Size || 0) / 4),
          source: this.name,
          sourceUri: `s3://${this.bucketName}/${object.Key}`,
        });
      }
    }
    
    return {
      provider: this.name,
      totalCount: resources.length,
      resources,
      version: new Date().toISOString(),
      timestamp: new Date(),
      categories: [...new Set(resources.map(r => r.category))],
      stats: { byCategory: {}, totalTokens: 0, topTags: [] },
    };
  }

  async fetchResource(id: string, category: string): Promise<RemoteResource> {
    const key = `${category}/${id}.md`;
    
    const command = new GetObjectCommand({
      Bucket: this.bucketName,
      Key: key,
    });
    
    const response = await this.s3Client.send(command);
    const content = await response.Body?.transformToString() || "";
    
    // Parse markdown with frontmatter
    const parsed = matter(content);
    
    return {
      id,
      category: category as any,
      title: parsed.data.title || id,
      description: parsed.data.description || "",
      tags: parsed.data.tags || [],
      capabilities: parsed.data.capabilities || [],
      useWhen: parsed.data.useWhen || [],
      estimatedTokens: Math.ceil(content.length / 4),
      source: this.name,
      sourceUri: `s3://${this.bucketName}/${key}`,
      content: parsed.content,
    };
  }

  // ... implement other methods
}

Next Steps


Development questions? Check the main documentation or open an issue.