返回 Skill 列表
extension
分类: 开发与工程无需 API Key

mini-apps

通过Mini-Apps工具链创建独立的React迷你应用。当需要构建应用程序、表单、调度器、仪表板或可共享的Web组件时使用。不要直接将应用程序代码写入仓库。

person作者: jakexiaohubgithub

Mini-Apps Creation Skill

Create standalone React applications using the Mini-Apps architecture. Use this skill when asked to create apps, forms, schedulers, dashboards, or any shareable web component.

Trigger Phrases

Use this skill when the user says:

  • "Create an app to..."
  • "Build a form for..."
  • "Make a scheduling app like Calendly"
  • "Create a poll/survey"
  • "Build a dashboard to show..."
  • "Generate an artifact"
  • "Create a mini-app"

CRITICAL: What NOT to Do

NEVER do the following when asked to create an app:

  1. ❌ Write code directly to project files using write or edit tools
  2. ❌ Use bash to create files or run npm commands
  3. ❌ Modify src/, apps/, or any project source files directly
  4. ❌ Create new TypeScript/React files manually

ALWAYS use the Mini-Apps tools instead:

  1. ✅ Use ai_first_create_app to generate new apps
  2. ✅ Use ai_first_update_app to modify existing apps
  3. ✅ Apps are created via PR for review, not direct commits

Available Tools

| Tool | Purpose | | --------------------- | ------------------------------ | | ai_first_create_app | Create a new app from a prompt | | ai_first_list_apps | List all available apps | | ai_first_get_app | Get details of a specific app | | ai_first_share_app | Generate a shareable link | | ai_first_update_app | Update an existing app |

Workflow

Creating a New App

  1. Understand the request: Ask clarifying questions if needed
  2. Craft a detailed prompt: Include functionality, UI elements, integrations
  3. Call the tool:
ai_first_create_app({
  prompt: "Create a meeting scheduler app that shows a calendar with available time slots. Users can select a date and time, enter their name and email, and confirm the booking. The app should integrate with Google Calendar to check availability.",
  name: "meeting-scheduler"  // optional
})
  1. Share results: The tool returns:
    • prUrl: Link to the PR for review
    • previewUrl: Where the app will be hosted
    • explanation: What the AI generated

Updating an Existing App

ai_first_update_app({
  name: "meeting-scheduler",
  updateRequest: "Add a dropdown to select meeting duration (15, 30, 45, or 60 minutes)"
})

Writing Good Prompts

The quality of the generated app depends on the prompt. Include:

  1. Core functionality: What should the app do?
  2. UI elements: Calendar, forms, buttons, lists, etc.
  3. Integrations: Calendar, Slack, email, etc.
  4. User flow: Step-by-step what happens when user interacts

Example Prompts

Meeting Scheduler:

Create a meeting scheduler app similar to Calendly. Features:
- Calendar view showing the next 2 weeks
- Time slots in 30-minute increments
- Form to collect: name, email, meeting topic
- Confirmation message after booking
- Integration with Google Calendar for availability

Feedback Form:

Build a feedback collection form with:
- 5-star rating for different categories (quality, speed, communication)
- Text area for detailed comments
- Optional name/email fields
- Submit button that sends results to Slack
- Thank you message after submission

Team Poll:

Create a poll app for team decisions:
- Question text at the top
- Multiple choice options (2-6)
- Show results as percentage bars after voting
- Allow changing vote before closing
- Results visible to all participants

Architecture Notes

Mini-Apps are:

  • Standalone React applications in the apps/ directory
  • Built with Vite and shared UI components
  • Have their own APP.yaml manifest defining permissions
  • Can access calendar, Slack, scheduler via a runtime bridge
  • Shared via secret links with optional expiry

The ai_first_create_app tool:

  1. Uses Claude to generate React code
  2. Creates the app in a git worktree
  3. Commits and pushes to a feature branch
  4. Opens a PR for review
  5. Returns the PR URL

This ensures:

  • ✅ Code review before deployment
  • ✅ No direct changes to production
  • ✅ Proper git history
  • ✅ Design system compliance

Bridge Capabilities Reference

Mini-apps access backend services through the useBridge() hook. The bridge provides five capability domains:

Import and Initialize

import { useBridge } from '../../_shared/hooks';

function MyApp() {
  const { bridge, isReady, isPreviewMode } = useBridge();

  if (!isReady) return <div>Loading...</div>;

  // Use bridge.calendar, bridge.scheduler, etc.
}

1. Calendar Integration (Google Calendar)

Permission required in APP.yaml:

permissions:
  calendar:
    read: true # For listEvents
    write: true # For createEvent, updateEvent, deleteEvent

Available methods:

| Method | Description | Parameters | | ----------------------------------------- | ------------------------ | ----------------------------------------------------- | | bridge.calendar.listEvents(start, end) | Get events in date range | start: Date, end: Date | | bridge.calendar.createEvent(params) | Create a calendar event | See below | | bridge.calendar.updateEvent(id, params) | Update existing event | eventId: string, params: Partial<CreateEventParams> | | bridge.calendar.deleteEvent(id) | Delete an event | eventId: string |

CreateEventParams:

{
  summary: string;          // Event title
  description?: string;     // Event description
  start: Date;              // Start time
  duration: number;         // Duration in minutes
  attendees?: string[];     // Email addresses
  location?: string;        // Location or video link
  createMeetLink?: boolean; // Auto-create Google Meet
}

Example - Create a meeting:

const event = await bridge.calendar.createEvent({
  summary: 'Team Standup',
  description: 'Daily sync meeting',
  start: new Date('2026-01-20T10:00:00'),
  duration: 30,
  attendees: ['alice@company.com', 'bob@company.com'],
  createMeetLink: true,
});
console.log('Created event:', event.id, 'Meet link:', event.meetLink);

Example - Check availability:

const events = await bridge.calendar.listEvents(
  new Date(), // Start of range
  new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days ahead
);
const busyTimes = events.map((e) => ({ start: e.start, end: e.end }));

2. Scheduler (Built-in Capability)

Schedule messages to be sent via WhatsApp or Slack at specific times.

Permission required in APP.yaml:

capabilities:
  scheduler:
    enabled: true
    max_jobs: 10 # Maximum concurrent scheduled jobs

Available methods:

| Method | Description | Parameters | | ------------------------------------ | ----------------------- | --------------- | | bridge.scheduler.createJob(params) | Schedule a message | See below | | bridge.scheduler.listJobs() | List all scheduled jobs | None | | bridge.scheduler.cancelJob(id) | Cancel a scheduled job | jobId: number |

CreateJobParams:

{
  name: string;                              // Unique job name
  scheduleType: 'once' | 'recurring' | 'cron';
  runAt?: Date;                              // For 'once' type
  cronExpression?: string;                   // For 'cron' type (e.g., "0 9 * * 1-5")
  intervalMinutes?: number;                  // For 'recurring' type
  provider: 'whatsapp' | 'slack';
  target: string;                            // Channel/phone/email
  messageTemplate: string;                   // Message to send
}

Example - One-time reminder:

await bridge.scheduler.createJob({
  name: `reminder-${eventId}`,
  scheduleType: 'once',
  runAt: new Date(meetingTime.getTime() - 15 * 60 * 1000), // 15 min before
  provider: 'slack',
  target: '#team-channel',
  messageTemplate: '📅 Reminder: Team meeting starts in 15 minutes!',
});

Example - Daily standup reminder:

await bridge.scheduler.createJob({
  name: 'daily-standup-reminder',
  scheduleType: 'cron',
  cronExpression: '0 9 * * 1-5', // 9 AM, Mon-Fri
  provider: 'slack',
  target: '#engineering',
  messageTemplate: '🌅 Good morning! Time for standup.',
});

3. Webhooks (Built-in Capability)

Receive data from external services via webhook endpoints.

Permission required in APP.yaml:

capabilities:
  webhooks:
    enabled: true

Available methods:

| Method | Description | Parameters | | --------------------------------------------------- | ------------------------ | ------------------------------------------------ | | bridge.webhooks.getEndpointUrl(name) | Get webhook URL to share | endpointName: string | | bridge.webhooks.onWebhookReceived(name, callback) | Listen for incoming data | endpointName: string, callback: (data) => void |

Example - Form submission webhook:

// Get the webhook URL to embed in external forms
const webhookUrl = await bridge.webhooks.getEndpointUrl('form-submit');
console.log('Share this URL:', webhookUrl);

// Listen for incoming submissions
useEffect(() => {
  const cleanup = bridge.webhooks.onWebhookReceived('form-submit', (data) => {
    console.log('Received submission:', data);
    setSubmissions((prev) => [...prev, data]);
  });
  return cleanup;
}, [bridge]);

4. Storage (Backend Persistence)

Persist data to the backend database. Unlike localStorage (browser-only), storage data persists across devices and sessions.

Permission required in APP.yaml:

capabilities:
  storage:
    enabled: true

Available methods:

| Method | Description | Parameters | Returns | | -------------------------------- | --------------------- | ----------------------------- | -------------------- | | bridge.storage.set(key, value) | Store a value | key: string, value: unknown | Promise<void> | | bridge.storage.get(key) | Retrieve a value | key: string | Promise<T \| null> | | bridge.storage.delete(key) | Delete a key | key: string | Promise<boolean> | | bridge.storage.list() | List all keys for app | None | Promise<string[]> | | bridge.storage.clear() | Delete all app data | None | Promise<number> |

Example - Persist todo list:

// Save todos
await bridge.storage.set('todos', [
  { id: '1', text: 'Buy milk', completed: false },
  { id: '2', text: 'Walk dog', completed: true },
]);

// Load todos
const todos = await bridge.storage.get<Todo[]>('todos');
if (todos) {
  setTodos(todos);
}

// Delete a specific key
await bridge.storage.delete('todos');

// List all keys
const keys = await bridge.storage.list();
console.log('Stored keys:', keys);

// Clear all app data
const deletedCount = await bridge.storage.clear();

Example - User preferences:

interface UserPrefs {
  theme: 'light' | 'dark';
  notifications: boolean;
}

// Save preferences
await bridge.storage.set('prefs', { theme: 'dark', notifications: true });

// Load with type safety
const prefs = await bridge.storage.get<UserPrefs>('prefs');
if (prefs) {
  setTheme(prefs.theme);
}

localStorage vs bridge.storage:

| Feature | localStorage | bridge.storage | | ------------------- | ------------ | --------------------- | | Persistence | Browser only | Backend database | | Cross-device | No | Yes | | Storage limit | ~5MB | Unlimited (practical) | | Data format | String only | Any JSON-serializable | | Survives clear data | No | Yes | | Requires capability | No | Yes (in APP.yaml) |

When to use each:

  • localStorage: Quick, temporary data; draft content; UI state
  • bridge.storage: User data that must persist; shared state; production data

5. Slack Messaging

Send messages to Slack channels or users.

Permission required in APP.yaml:

permissions:
  slack:
    read: false # Not yet implemented
    write: true # For sendDM, sendChannel

Available methods:

| Method | Description | Parameters | | ---------------------------------- | ------------------- | ------------------------------------- | | bridge.slack.sendDM(params) | Send direct message | { target: string, message: string } | | bridge.slack.sendChannel(params) | Post to channel | { target: string, message: string } |

Example - Send notification:

await bridge.slack.sendChannel({
  target: '#notifications',
  message: `New booking: ${userName} scheduled a meeting for ${formatDate(dateTime)}`,
});

Example - Send confirmation DM:

await bridge.slack.sendDM({
  target: userEmail, // Slack will resolve to user
  message: `Your meeting "${title}" has been confirmed for ${formatDate(dateTime)}.`,
});

6. App Metadata

Access app configuration and sharing info.

Available methods (no permissions needed):

| Method | Description | | -------------------------- | ------------------------------- | | bridge.app.getManifest() | Get APP.yaml as object | | bridge.app.getShareUrl() | Get shareable link for this app |

Example:

const shareUrl = await bridge.app.getShareUrl();
navigator.clipboard.writeText(shareUrl);
alert('Link copied!');

APP.yaml Permission Reference

Every mini-app must declare its permissions in APP.yaml:

name: my-app
version: 1.0.0
title: My App Title
description: What the app does

# External service permissions
permissions:
  calendar:
    read: true # Can view events
    write: true # Can create/modify events
  slack:
    read: false
    write: true # Can send messages

# Built-in capabilities
capabilities:
  scheduler:
    enabled: true
    max_jobs: 5
  webhooks:
    enabled: true
  storage:
    enabled: true

# Sharing configuration
sharing:
  mode: secret_link # or 'public' or 'private'
  expires_after_days: 30

# Build configuration
build:
  entry: src/App.tsx
  output: dist/

Permission Rules:

  • Bridge calls will fail if the required permission is not declared
  • Request only the permissions the app actually needs
  • read vs write are checked separately

Crafting Prompts with Integrations

When generating apps, include specific integration requirements in the prompt:

Good prompt with integrations:

Create a meeting scheduler app with these features:
- Form to collect: title, attendees (comma-separated emails), date/time, duration
- Use bridge.calendar.createEvent to book the meeting with createMeetLink: true
- Use bridge.scheduler.createJob to schedule a Slack reminder 15 minutes before
- Show success message with the Google Meet link
- Permissions needed: calendar (read+write), scheduler enabled

The generated APP.yaml should include:

permissions:
  calendar:
    read: true
    write: true
capabilities:
  scheduler:
    enabled: true
    max_jobs: 5

Post-Creation Verification

After creating or updating an app, always verify it compiles:

Step 1: Build the App

cd apps/<app-name> && npm install && npm run build

Step 2: Check for TypeScript Errors

If the build fails, common issues include:

  1. React types not found (Cannot find module 'react'):

    • Ensure @types/react and @types/react-dom are in devDependencies
    • Check that tsconfig.json includes proper typeRoots
  2. Shared component type errors (missing className, children):

    • Props interfaces should extend React.HTMLAttributes<HTMLElement>
    • Example: interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
  3. Index signature errors:

    • Add [key: string]: unknown; to interface if needed for dynamic props

Step 3: Verify the App Loads

After a successful build:

  1. Run curl -s -X POST http://localhost/api/apps/reload to refresh the cache
  2. Check status: curl http://localhost/api/apps/<app-name> should show "status": "published", "isBuilt": true
  3. Preview at: http://localhost/apps/<app-name>/

tsconfig.json Template for Apps

If an app has compilation issues with shared components, ensure the tsconfig includes:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "baseUrl": ".",
    "paths": {
      "@shared/*": ["../_shared/*"]
    },
    "typeRoots": ["./node_modules/@types"]
  },
  "include": ["src", "../_shared"]
}

Dashboard Integration

Apps are viewable in the Dashboard:

  1. Navigate to Mini-Apps in the sidebar
  2. See all apps with their build status (Published/Building)
  3. Click Preview to test a built app
  4. Click Copy Link to share the preview URL

Serving Mini-Apps (Static Files)

Mini-apps are served as static files from the dashboard server at /apps/:appName/. This section covers the architecture and configuration required.

Dashboard Server Architecture

The dashboard server (packages/dashboard/src/server/index.ts) serves mini-apps via Express static file serving:

// In createDashboardServer()
if (services.appsService) {
  app.use('/apps/:appName', (req, res, next) => {
    const app = appsService.getApp(req.params.appName);
    if (!app || !app.isBuilt) {
      return res.status(404).json({ error: 'App not found or not built' });
    }

    // Serve static files from app's dist directory
    express.static(app.distPath)(req, res, () => {
      // Fallback to index.html for SPA routing
      const indexPath = path.join(app.distPath, 'index.html');
      if (fs.existsSync(indexPath)) {
        res.sendFile(indexPath);
      } else {
        next();
      }
    });
  });
}

Nginx routes /apps/ to the dashboard server:

location /apps/ {
    proxy_pass http://dashboard_api_local/apps/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
}

Vite Base Path Configuration

CRITICAL: Mini-apps must use relative asset paths to work correctly when served at /apps/:appName/.

Every mini-app's vite.config.ts must include:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  base: './', // REQUIRED: Use relative paths for assets
  resolve: {
    alias: {
      '@shared': path.resolve(__dirname, '../_shared'),
    },
  },
  build: {
    outDir: 'dist',
    emptyOutDir: true,
  },
});

Why base: './' is required:

Without this setting, Vite generates absolute paths like /assets/index.js. When the app is served at /apps/my-app/, the browser tries to load /assets/index.js from the root, which fails.

With base: './', paths become ./assets/index.js, which resolves correctly relative to the app's URL.

Troubleshooting Asset Loading Issues

| Symptom | Cause | Solution | | -------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- | | JS/CSS returns HTML | Absolute paths (/assets/...) routed to Vite dev server | Add base: './' to vite.config.ts and rebuild | | 404 on assets | Missing base path config | Ensure base: './' in vite.config.ts | | App loads but shows blank | JS execution error | Check browser console; rebuild with correct base | | Preview button returns 404 | App not built or routes not configured | Run npm run build, ensure dashboard has /apps/:appName route |

Verifying App Serving

After building an app, verify it's accessible:

# Check HTML is served
curl http://localhost:3080/apps/my-app/

# Check assets use relative paths (should see ./assets/...)
curl http://localhost:3080/apps/my-app/ | grep -o 'src="[^"]*"'

# Check assets are served correctly (should return JS, not HTML)
curl http://localhost:3080/apps/my-app/assets/index-xxxxx.js | head -c 100

Troubleshooting

| Issue | Solution | | ------------------------------------------ | ----------------------------------------------- | | App shows "Building" status | Run npm run build in the app directory | | Preview returns 404 | Ensure the app has a dist/ folder after build | | API shows 503 "Apps service not available" | Restart the dev server | | Shared components not found | Check tsconfig.json paths and includes | | Assets return HTML instead of JS/CSS | Add base: './' to vite.config.ts and rebuild |

Database Schema Design Patterns

When adding persistent storage capabilities to mini-apps, follow these patterns for SQLite database services.

Database Initialization

Use better-sqlite3 for synchronous SQLite operations:

import Database from 'better-sqlite3';
import { createServiceLogger } from '@orientbot/core';

const logger = createServiceLogger('storage-db');

export class MyDatabase {
  private db: Database.Database;

  constructor(dbPath?: string) {
    const path = dbPath || process.env.SQLITE_DB_PATH || './data/orient.db';
    this.db = new Database(path);
    this.db.pragma('journal_mode = WAL'); // Better concurrent access
    this.db.pragma('foreign_keys = ON');
  }

  // Always close when shutting down
  close(): void {
    this.db.close();
  }
}

Key points:

  • Use WAL mode for better concurrent read performance
  • Enable foreign keys if using relationships
  • SQLite operations are synchronous, simplifying code
  • Always implement close() for graceful shutdown

Table Schema Design

Follow these conventions for mini-app database tables:

CREATE TABLE IF NOT EXISTS app_feature (
  -- Primary key
  id INTEGER PRIMARY KEY AUTOINCREMENT,

  -- App identification (always required for multi-tenant isolation)
  app_name TEXT NOT NULL,

  -- Your feature-specific columns
  key TEXT NOT NULL,
  value TEXT NOT NULL,           -- Store JSON as TEXT

  -- Timestamps (always include these)
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch()),

  -- Unique constraints for app-scoped uniqueness
  UNIQUE(app_name, key)
);

Best practices:

  • Always include app_name for multi-tenant isolation
  • Use INTEGER PRIMARY KEY AUTOINCREMENT for auto-incrementing IDs
  • Store timestamps as Unix epoch integers
  • Store JSON as TEXT (SQLite has no native JSON type but supports json functions)
  • Add UNIQUE constraints for natural keys within an app scope

Index Design

Create indexes for common query patterns:

-- Composite index for app-scoped lookups (most common pattern)
CREATE INDEX IF NOT EXISTS idx_app_feature_app_key
  ON app_feature(app_name, key);

-- Single column index if you query by app_name alone
CREATE INDEX IF NOT EXISTS idx_app_feature_app_name
  ON app_feature(app_name);

-- Partial index for enabled/active records
CREATE INDEX IF NOT EXISTS idx_app_feature_active
  ON app_feature(app_name) WHERE enabled = 1;

Index guidelines:

  • Create indexes for columns used in WHERE clauses
  • Composite indexes should match query column order
  • Use partial indexes for frequently filtered conditions

Transaction Handling

Use transactions for multi-statement operations:

initialize(): void {
  const createTables = this.db.transaction(() => {
    this.db.exec(`CREATE TABLE IF NOT EXISTS ...`);
    this.db.exec(`CREATE INDEX IF NOT EXISTS ...`);
  });

  try {
    createTables();
    logger.info('Database initialized successfully');
  } catch (error) {
    logger.error('Database initialization failed', { error });
    throw error;
  }
}

Transaction rules:

  • Use db.transaction() for atomic operations
  • Transactions in better-sqlite3 are automatic COMMIT on success, ROLLBACK on error
  • Log both success and failure for debugging

Query Patterns

Simple queries:

get(appName: string, key: string): unknown | null {
  const stmt = this.db.prepare(
    'SELECT value FROM app_storage WHERE app_name = ? AND key = ?'
  );
  const row = stmt.get(appName, key) as { value: string } | undefined;
  return row ? JSON.parse(row.value) : null;
}

Upsert pattern (INSERT OR REPLACE):

set(appName: string, key: string, value: unknown): void {
  const stmt = this.db.prepare(`
    INSERT INTO app_storage (app_name, key, value, updated_at)
    VALUES (?, ?, ?, unixepoch())
    ON CONFLICT (app_name, key)
    DO UPDATE SET value = excluded.value, updated_at = unixepoch()
  `);
  stmt.run(appName, key, JSON.stringify(value));
}

Returning results after modification:

create(data: CreateInput): Record {
  const stmt = this.db.prepare(`
    INSERT INTO my_table (name, value)
    VALUES (?, ?)
    RETURNING *
  `);
  const row = stmt.get(data.name, data.value);
  return this.rowToRecord(row);
}

Row Mapping

Convert database rows to TypeScript types:

interface StorageEntry {
  appName: string;
  key: string;
  value: unknown;
  createdAt: Date;
  updatedAt: Date;
}

private rowToEntry(row: Record<string, unknown>): StorageEntry {
  return {
    appName: row.app_name as string,      // snake_case to camelCase
    key: row.key as string,
    value: JSON.parse(row.value as string),  // Parse JSON manually
    createdAt: new Date((row.created_at as number) * 1000),
    updatedAt: new Date((row.updated_at as number) * 1000),
  };
}

Initialization Pattern

Use an initialized flag to prevent duplicate setup:

export class MyDatabase {
  private initialized: boolean = false;

  initialize(): void {
    if (this.initialized) return; // Idempotent - safe to call multiple times

    // ... create tables and indexes ...

    this.initialized = true;
  }
}

Complete Example: StorageDatabase

Here's the full pattern used by the storage capability:

import Database from 'better-sqlite3';
import { createServiceLogger } from '@orientbot/core';

const logger = createServiceLogger('storage-db');

export class StorageDatabase {
  private db: Database.Database;
  private initialized: boolean = false;

  constructor(dbPath?: string) {
    const path = dbPath || process.env.SQLITE_DB_PATH || './data/orient.db';
    this.db = new Database(path);
    this.db.pragma('journal_mode = WAL');
  }

  initialize(): void {
    if (this.initialized) return;

    this.db.exec(`
      CREATE TABLE IF NOT EXISTS app_storage (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        app_name TEXT NOT NULL,
        key TEXT NOT NULL,
        value TEXT NOT NULL,
        created_at INTEGER DEFAULT (unixepoch()),
        updated_at INTEGER DEFAULT (unixepoch()),
        UNIQUE(app_name, key)
      )
    `);

    this.db.exec(`
      CREATE INDEX IF NOT EXISTS idx_app_storage_app_key
        ON app_storage(app_name, key);
    `);

    this.initialized = true;
  }

  set(appName: string, key: string, value: unknown): void {
    const stmt = this.db.prepare(`
      INSERT INTO app_storage (app_name, key, value, updated_at)
      VALUES (?, ?, ?, unixepoch())
      ON CONFLICT (app_name, key)
      DO UPDATE SET value = excluded.value, updated_at = unixepoch()
    `);
    stmt.run(appName, key, JSON.stringify(value));
  }

  get(appName: string, key: string): unknown | null {
    const stmt = this.db.prepare('SELECT value FROM app_storage WHERE app_name = ? AND key = ?');
    const row = stmt.get(appName, key) as { value: string } | undefined;
    return row ? JSON.parse(row.value) : null;
  }

  delete(appName: string, key: string): boolean {
    const stmt = this.db.prepare('DELETE FROM app_storage WHERE app_name = ? AND key = ?');
    const result = stmt.run(appName, key);
    return result.changes > 0;
  }

  list(appName: string): string[] {
    const stmt = this.db.prepare('SELECT key FROM app_storage WHERE app_name = ? ORDER BY key');
    const rows = stmt.all(appName) as { key: string }[];
    return rows.map((row) => row.key);
  }

  clear(appName: string): number {
    const stmt = this.db.prepare('DELETE FROM app_storage WHERE app_name = ?');
    const result = stmt.run(appName);
    return result.changes;
  }

  close(): void {
    this.db.close();
  }
}

Bridge API Endpoint Handler Patterns

The bridge API (/api/apps/bridge) handles method invocations from mini-apps. This section documents the request/response format and handler patterns.

Request Format

All bridge calls use POST with JSON body:

// Frontend call (from useBridge.ts)
const response = await fetch('/api/apps/bridge', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    appName: 'my-app', // Required: identifies the app
    method: 'storage.get', // Required: the method to invoke
    params: { key: 'todos' }, // Optional: method-specific parameters
  }),
});

Response Format

Success response:

{
  "data": {
    /* method-specific result */
  }
}

Error responses:

| Status | Error | When | | ------ | --------------------------------------------- | ----------------------------------- | | 400 | appName and method are required | Missing required fields | | 400 | key is required | Missing method-specific parameter | | 403 | Storage capability not enabled for this app | Capability not declared in APP.yaml | | 404 | App "name" not found | App doesn't exist | | 501 | Method "x" not implemented | Unknown method | | 503 | Storage service not available | Backend service not initialized | | 500 | Bridge call failed | Unexpected server error |

Handler Structure

The bridge endpoint uses a switch statement for method routing:

router.post('/bridge', async (req: Request, res: Response) => {
  try {
    const { appName, method, params } = req.body;

    // 1. Validate required fields
    if (!appName || !method) {
      return res.status(400).json({ error: 'appName and method are required' });
    }

    // 2. Get the app (validates it exists)
    const app = appsService.getApp(appName);
    if (!app) {
      return res.status(404).json({ error: `App "${appName}" not found` });
    }

    logger.debug('Bridge call', { appName, method, params });

    // 3. Route to method handler
    switch (method) {
      case 'storage.set': {
        // Check service availability
        if (!bridgeServices?.storageDb) {
          return res.status(503).json({ error: 'Storage service not available' });
        }
        // Check capability
        const cap = app.manifest.capabilities?.storage;
        if (!cap?.enabled) {
          return res.status(403).json({ error: 'Storage capability not enabled' });
        }
        // Validate params
        const { key, value } = params || {};
        if (!key || typeof key !== 'string') {
          return res.status(400).json({ error: 'key is required' });
        }
        // Execute
        await bridgeServices.storageDb.set(appName, key, value);
        return res.json({ data: { success: true } });
      }

      // ... other methods

      default:
        logger.warn('Unknown bridge method', { appName, method });
        return res.status(501).json({ error: `Method "${method}" not implemented` });
    }
  } catch (error) {
    logger.error('Bridge call failed', {
      error: error instanceof Error ? error.message : String(error),
    });
    res.status(500).json({ error: 'Bridge call failed' });
  }
});

Method Handler Pattern

Each method handler follows this pattern:

case 'category.methodName': {
  // 1. Check service availability (503 if not available)
  if (!bridgeServices?.myService) {
    return res.status(503).json({ error: 'MyService not available' });
  }

  // 2. Check capability (403 if not enabled)
  const capability = app.manifest.capabilities?.myCapability;
  if (!capability?.enabled) {
    return res.status(403).json({ error: 'MyCapability not enabled for this app' });
  }

  // 3. Extract and validate parameters (400 if invalid)
  const { requiredParam, optionalParam } = params || {};
  if (!requiredParam || typeof requiredParam !== 'string') {
    return res.status(400).json({ error: 'requiredParam is required' });
  }

  // 4. Execute the operation
  const result = await bridgeServices.myService.doSomething(appName, requiredParam);

  // 5. Return success with data wrapper
  return res.json({ data: result });
}

Capability Checking Pattern

Always check capability before processing:

// For capabilities (scheduler, webhooks, storage)
const cap = app.manifest.capabilities?.storage;
if (!cap?.enabled) {
  return res.status(403).json({ error: 'Storage capability not enabled for this app' });
}

// For permissions (calendar, slack, jira)
const perm = app.manifest.permissions?.calendar;
if (!perm?.write) {
  return res.status(403).json({ error: 'Calendar write permission not granted' });
}

Parameter Validation Patterns

// Required string parameter
const { key } = params || {};
if (!key || typeof key !== 'string') {
  return res.status(400).json({ error: 'key is required' });
}

// Required number parameter
const { id } = params || {};
if (typeof id !== 'number') {
  return res.status(400).json({ error: 'id must be a number' });
}

// Optional parameter with default
const { limit = 100 } = params || {};

// Array parameter
const { items } = params || {};
if (!Array.isArray(items)) {
  return res.status(400).json({ error: 'items must be an array' });
}

Return Value Patterns

// Simple success
return res.json({ data: { success: true } });

// Return single value
return res.json({ data: value }); // value can be null

// Return object
return res.json({ data: { id: 1, name: 'test' } });

// Return array
return res.json({ data: ['key1', 'key2', 'key3'] });

// Return with count
return res.json({ data: { deleted: true } });
return res.json({ data: { cleared: 5 } });

Frontend Bridge Call Pattern

The frontend callBridge function handles the request/response:

async function callBridge<T>(method: string, params: Record<string, unknown>): Promise<T> {
  // Check permissions before making request
  checkPermissions(method, capabilities);

  const response = await fetch('/api/apps/bridge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ appName, method, params }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error || 'Bridge call failed');
  }

  const result = await response.json();
  return result.data as T;
}

Adding a New Method

When adding a new bridge method:

  1. Choose a method name: Use category.action format (e.g., storage.set, calendar.listEvents)

  2. Add the handler case in the switch statement following the pattern above

  3. Update frontend bridge:

    // In useBridge.ts AppBridge interface
    myCategory: {
      myMethod: (params) => callBridge('myCategory.myMethod', params),
    },
    
  4. Add permission check if needed:

    // In checkPermissions function
    if (method.startsWith('myCategory.')) {
      if (!capabilities?.myCategory?.enabled) {
        throw new Error('Capability denied: myCategory not enabled in APP.yaml');
      }
    }
    

Frontend Bridge Implementation Patterns

This section covers how to implement bridge methods in the frontend (apps/_shared/hooks/useBridge.ts).

The callBridge Utility Function

The callBridge function is the core utility for making bridge API calls:

/**
 * Make a bridge API call with type safety
 * @template T - The expected return type
 * @param method - The method name (e.g., 'storage.get')
 * @param params - Method-specific parameters
 * @returns Promise resolving to the typed result
 */
async function callBridge<T>(method: string, params: Record<string, unknown>): Promise<T> {
  // 1. Check permissions before making the network request
  checkPermissions(method, capabilities);

  // 2. Make the API call
  const response = await fetch('/api/apps/bridge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ appName, method, params }),
  });

  // 3. Handle errors
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error || 'Bridge call failed');
  }

  // 4. Return typed result
  const result = await response.json();
  return result.data as T;
}

Type-Safe Bridge Method Implementations

Define typed methods in the AppBridge interface:

export interface AppBridge {
  storage: {
    set(key: string, value: unknown): Promise<void>;
    get<T = unknown>(key: string): Promise<T | null>;
    delete(key: string): Promise<boolean>;
    list(): Promise<string[]>;
    clear(): Promise<number>;
  };
}

Implement methods with proper typing:

const bridge: AppBridge = {
  storage: {
    // Simple void return
    set: async (key: string, value: unknown): Promise<void> => {
      await callBridge('storage.set', { key, value });
    },

    // Generic return type
    get: async <T = unknown>(key: string): Promise<T | null> => {
      return callBridge<T | null>('storage.get', { key });
    },

    // Extract nested result
    delete: async (key: string): Promise<boolean> => {
      const result = await callBridge<{ deleted: boolean }>('storage.delete', { key });
      return result.deleted;
    },

    // Direct array return
    list: (): Promise<string[]> => callBridge('storage.list', {}),

    // Extract count from result
    clear: async (): Promise<number> => {
      const result = await callBridge<{ cleared: number }>('storage.clear', {});
      return result.cleared;
    },
  },
};

Permission Checking Pattern

Check capabilities before making API calls:

interface Capabilities {
  scheduler?: { enabled?: boolean; max_jobs?: number };
  webhooks?: { enabled?: boolean };
  storage?: { enabled?: boolean };
}

function checkPermissions(method: string, capabilities: Capabilities | undefined): void {
  // Scheduler capability
  if (method.startsWith('scheduler.')) {
    if (!capabilities?.scheduler?.enabled) {
      throw new Error('Capability denied: scheduler not enabled in APP.yaml');
    }
  }

  // Webhooks capability
  if (method.startsWith('webhooks.')) {
    if (!capabilities?.webhooks?.enabled) {
      throw new Error('Capability denied: webhooks not enabled in APP.yaml');
    }
  }

  // Storage capability
  if (method.startsWith('storage.')) {
    if (!capabilities?.storage?.enabled) {
      throw new Error('Capability denied: storage not enabled in APP.yaml');
    }
  }
}

Error Handling Patterns

Handle bridge errors in your app:

// Pattern 1: Try-catch with user feedback
const loadData = async () => {
  try {
    const data = await bridge.storage.get<MyData>('key');
    setData(data);
  } catch (error) {
    console.error('Failed to load data:', error);
    setError('Could not load data. Please try again.');
  }
};

// Pattern 2: Silent failure with fallback
const loadWithFallback = async () => {
  try {
    const data = await bridge.storage.get<MyData>('key');
    return data ?? defaultData;
  } catch {
    return defaultData;
  }
};

// Pattern 3: Optimistic update with rollback
const saveData = async (newData: MyData) => {
  const oldData = data;
  setData(newData); // Optimistic update

  try {
    await bridge.storage.set('key', newData);
  } catch (error) {
    setData(oldData); // Rollback on failure
    console.error('Failed to save:', error);
  }
};

Async/Await Best Practices

// Good: Separate loading state from ready state
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
  if (!isReady) return;

  const loadData = async () => {
    try {
      const stored = await bridge.storage.get<Data>('key');
      if (stored) setData(stored);
    } finally {
      setIsLoading(false);
    }
  };

  loadData();
}, [isReady, bridge]);

// Good: Save after state update
const updateData = async (newData: Data) => {
  setData(newData);
  await bridge.storage.set('key', newData);
};

// Good: Memoize save function to avoid dependency issues
const saveData = useCallback(
  async (data: Data) => {
    try {
      await bridge.storage.set('key', data);
    } catch (error) {
      console.error('Save failed:', error);
    }
  },
  [bridge]
);

Adding a New Frontend Bridge Method

  1. Update the interface in AppBridge:

    export interface AppBridge {
      myCategory: {
        myMethod(param: string): Promise<Result>;
      };
    }
    
  2. Update the Capabilities interface:

    interface Capabilities {
      myCategory?: { enabled?: boolean };
    }
    
  3. Add permission check:

    if (method.startsWith('myCategory.')) {
      if (!capabilities?.myCategory?.enabled) {
        throw new Error('Capability denied: myCategory not enabled');
      }
    }
    
  4. Implement the method:

    myCategory: {
      myMethod: async (param: string): Promise<Result> => {
        return callBridge<Result>('myCategory.myMethod', { param });
      },
    },
    

Testing Bridge Methods

Mock the bridge in tests:

const mockBridge = {
  storage: {
    set: vi.fn().mockResolvedValue(undefined),
    get: vi.fn().mockResolvedValue(null),
    delete: vi.fn().mockResolvedValue(true),
    list: vi.fn().mockResolvedValue([]),
    clear: vi.fn().mockResolvedValue(0),
  },
};

// Test usage
it('should save data', async () => {
  await mockBridge.storage.set('key', { foo: 'bar' });
  expect(mockBridge.storage.set).toHaveBeenCalledWith('key', { foo: 'bar' });
});

Implementing New Bridge Capabilities

This guide explains how to add a new capability to the mini-apps bridge (like storage, scheduler, webhooks). Follow these steps when implementing new backend services that mini-apps can access.

Architecture Overview

Bridge capabilities flow through these layers:

Frontend (useBridge.ts) → Bridge API (/api/apps/bridge) → Database Service → SQLite
     ↓                           ↓                              ↓
 Permission check           Route handler               SQL operations

Step 1: Define Types (packages/apps/src/types.ts)

Add a Zod schema and TypeScript type for the new capability:

/**
 * MyFeature capability configuration
 */
export const MyFeatureCapabilitySchema = z.object({
  enabled: z.boolean().default(false),
  // Add any configuration options here
  max_items: z.number().int().positive().optional(),
});

export type MyFeatureCapability = z.infer<typeof MyFeatureCapabilitySchema>;

Add to AppCapabilitiesSchema:

export const AppCapabilitiesSchema = z.object({
  scheduler: SchedulerCapabilitySchema.optional(),
  webhooks: WebhookCapabilitySchema.optional(),
  storage: StorageCapabilitySchema.optional(),
  myFeature: MyFeatureCapabilitySchema.optional(), // Add new capability
});

Update generateAppManifestTemplate() and serializeManifestToYaml() to include the new capability.

Step 2: Create Database Service (packages/dashboard/src/services/)

Create a new file myFeatureDatabase.ts:

import Database from 'better-sqlite3';
import { createServiceLogger } from '@orientbot/core';

const logger = createServiceLogger('myfeature-db');

export class MyFeatureDatabase {
  private db: Database.Database;
  private initialized: boolean = false;

  constructor(dbPath?: string) {
    const path = dbPath || process.env.SQLITE_DB_PATH || './data/orient.db';
    this.db = new Database(path);
    this.db.pragma('journal_mode = WAL');
  }

  initialize(): void {
    if (this.initialized) return;

    // Create your table
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS my_feature (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        app_name TEXT NOT NULL,
        -- your columns here
        created_at INTEGER DEFAULT (unixepoch()),
        updated_at INTEGER DEFAULT (unixepoch())
      )
    `);

    // Create indexes
    this.db.exec(`
      CREATE INDEX IF NOT EXISTS idx_my_feature_app ON my_feature(app_name);
    `);

    this.initialized = true;
    logger.info('MyFeature database tables initialized');
  }

  // Implement your CRUD methods
  create(appName: string, data: unknown): void { ... }
  get(appName: string, id: string): unknown { ... }
  list(appName: string): unknown[] { ... }
  delete(appName: string, id: string): boolean { ... }

  close(): void {
    this.db.close();
  }
}

Step 3: Register Service (packages/dashboard/src/server/index.ts)

Import and add to DashboardServices interface:

import { MyFeatureDatabase } from '../services/myFeatureDatabase.js';

export interface DashboardServices {
  // ... existing services
  myFeatureDb?: MyFeatureDatabase;
}

Initialize in initializeServices():

// Initialize myFeature database
const myFeatureDb = new MyFeatureDatabase(databaseUrl);
await myFeatureDb.initialize();
logger.info('MyFeature database initialized');

Add to the return object:

return {
  // ... existing services
  myFeatureDb,
};

Step 4: Add Bridge Handler (packages/dashboard/src/server/routes/apps.routes.ts)

Update the BridgeServices interface:

interface BridgeServices {
  storageDb?: StorageDatabase;
  myFeatureDb?: MyFeatureDatabase;
}

Add method handlers in the bridge endpoint switch statement:

case 'myFeature.create': {
  if (!bridgeServices?.myFeatureDb) {
    return res.status(503).json({ error: 'MyFeature service not available' });
  }
  // Check capability
  const cap = app.manifest.capabilities?.myFeature;
  if (!cap?.enabled) {
    return res.status(403).json({ error: 'MyFeature capability not enabled' });
  }
  // Validate and process
  const { data } = params || {};
  await bridgeServices.myFeatureDb.create(appName, data);
  return res.json({ data: { success: true } });
}

Step 5: Update Route Registration (packages/dashboard/src/server/routes.ts)

Pass the new service to apps routes:

if (appsService) {
  router.use(
    '/apps',
    createAppsRoutes(appsService, requireAuth, {
      storageDb,
      myFeatureDb, // Add new service
    })
  );
}

Step 6: Add Frontend Bridge Methods (apps/_shared/hooks/useBridge.ts)

Update the AppBridge interface:

export interface AppBridge {
  // ... existing capabilities

  myFeature: {
    create(data: unknown): Promise<void>;
    get(id: string): Promise<unknown | null>;
    list(): Promise<unknown[]>;
    delete(id: string): Promise<boolean>;
  };
}

Update the Capabilities interface:

interface Capabilities {
  scheduler?: { enabled?: boolean; max_jobs?: number };
  webhooks?: { enabled?: boolean };
  storage?: { enabled?: boolean };
  myFeature?: { enabled?: boolean };
}

Add permission check:

if (method.startsWith('myFeature.')) {
  if (!capabilities?.myFeature?.enabled) {
    throw new Error('Capability denied: myFeature not enabled in APP.yaml');
  }
}

Add the bridge implementation:

const bridge: AppBridge = {
  // ... existing capabilities

  myFeature: {
    create: (data) => callBridge('myFeature.create', { data }),
    get: (id) => callBridge('myFeature.get', { id }),
    list: () => callBridge('myFeature.list', {}),
    delete: async (id) => {
      const result = await callBridge<{ deleted: boolean }>('myFeature.delete', { id });
      return result.deleted;
    },
  },
};

Step 7: Write Tests

Create tests/dashboard/myFeature.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';

// Test database service methods
describe('MyFeatureDatabase Service', () => {
  // Test initialize, create, get, list, delete
});

// Test bridge API endpoints
describe('Bridge API MyFeature Endpoints', () => {
  // Test permission checks
  // Test CRUD operations
});

Step 8: Update Documentation

Add a section to this skill document describing the new capability, including:

  • APP.yaml configuration
  • Available methods
  • Example usage code
  • When to use this capability

Checklist

When adding a new bridge capability, ensure you have:

  • [ ] Added Zod schema and TypeScript type in packages/apps/src/types.ts
  • [ ] Created database service in packages/dashboard/src/services/
  • [ ] Added to DashboardServices interface and initialization
  • [ ] Added bridge method handlers in apps.routes.ts
  • [ ] Passed service to routes in routes.ts
  • [ ] Added frontend bridge interface and implementation in useBridge.ts
  • [ ] Added permission checking for the new capability
  • [ ] Written tests for database service and bridge API
  • [ ] Updated skill documentation with usage examples
  • [ ] Rebuilt the @orientbot/apps package (pnpm --filter @orientbot/apps exec tsc)