Skip to content

OpenCompanyApp/integrations

Repository files navigation

OpenCompany Integrations

Monorepo for all OpenCompany integration packages. Each package exposes tools that AI agents can call — from rendering diagrams to querying APIs to managing tasks.

Integrations are independent Composer packages built on a shared core. They work in any PHP 8.2+ application: OpenCompany (web), KosmoKrator (CLI), or your own consumer.

Repository Structure

core/                       Shared contracts, credential abstraction, Lua bridge, registry
packages/
  celestial/                Astronomy: moon phases, sunrise/sunset, planet positions, eclipses
  clickup/                  ClickUp project management: tasks, lists, folders, time tracking
  coingecko/                CoinGecko cryptocurrency: prices, market data, trending, charts
  constant-contact/         Constant Contact email marketing: contacts, campaigns, lists
  etsy/                     Etsy e-commerce: listings, orders, inventory, seller account
  exchangerate/             Currency exchange rates: 340+ fiat, crypto, and metal conversions
  google/                   Google Calendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
  mermaid/                  Mermaid diagram rendering to PNG
  microsoft-powerbi/        Microsoft Power BI: reports, datasets, workspaces, user info
  plantuml/                 PlantUML diagram rendering to PNG
  plausible/                Plausible Analytics: stats, realtime visitors, goals
  recruitee/                Recruitee ATS: job offers, candidates, departments
  splunk/                   Splunk log analytics: search, indexes, saved searches
  statuspage/               Atlassian Statuspage: incidents, components, status management
  tapfiliate/               Tapfiliate affiliate marketing: affiliates, conversions, tracking
  ticktick/                 TickTick task management with time tracking
  trustmrr/                 TrustMRR verified startup revenue data
  typst/                    Typst document rendering to PDF
  vegalite/                 Vega-Lite chart rendering to PNG
  worldbank/                World Bank economic indicators for 200+ countries

Architecture

┌─────────────────────────────────────────────────┐
│  Host Application (OpenCompany, KosmoKrator)     │
│                                                  │
│  ┌──────────┐   ┌───────────────────────────┐   │
│  │ Lua VM   │──▸│ LuaBridge                  │   │
│  │          │   │   functionMap → tool slugs  │   │
│  │ app.integrations.mermaid.render(...)       │   │
│  └──────────┘   └────────────┬──────────────┘   │
│                              │                   │
│  ┌───────────────────────────▼──────────────┐   │
│  │ ToolProviderRegistry                      │   │
│  │   ├─ mermaid    → MermaidToolProvider     │   │
│  │   ├─ plausible  → PlausibleToolProvider   │   │
│  │   ├─ clickup    → ClickUpToolProvider     │   │
│  │   └─ ...                                  │   │
│  └───────────────────────────┬──────────────┘   │
│                              │                   │
│  ┌───────────────────────────▼──────────────┐   │
│  │ ToolProvider.createTool(class, context)    │   │
│  │   → CredentialResolver for API keys       │   │
│  │   → AgentFileStorage for file output      │   │
│  │   → Tool.execute(args) → ToolResult       │   │
│  └──────────────────────────────────────────┘   │
└─────────────────────────────────────────────────┘

Key concepts:

  • Tool — A single callable action (e.g. "render a Mermaid diagram", "list ClickUp tasks"). Implements name(), description(), parameters(), execute().
  • ToolProvider — Groups related tools under an app name. Declares metadata, handles tool instantiation with credentials, and optionally provides Lua documentation.
  • ToolProviderRegistry — Singleton that collects all providers. The host queries it to discover available tools.
  • CredentialResolver — Abstraction for API keys. The default reads from config/ai-tools.php; OpenCompany swaps this for encrypted database storage.
  • LuaBridge — Routes app.integrations.{name}.{function}(...) calls from the Lua VM to PHP tool classes.

How It Works in OpenCompany

OpenCompany uses a code-first agent architecture — agents write and execute Lua scripts to access all workspace functionality, including integrations. The full pipeline:

  1. System prompt includes a namespace summary of all available Lua APIs (app.chat.*, app.integrations.mermaid.*, etc.)
  2. Agent calls lua_exec with Lua code like app.integrations.plausible.query_stats({...})
  3. Lua sandbox (32MB memory, 5s CPU limit) routes the call through the app.* metatable to LuaBridge
  4. LuaBridge maps the function path to a tool slug via LuaCatalogBuilder-generated function maps
  5. OpenCompanyLuaToolInvoker instantiates the tool via the ToolProvider and calls execute()
  6. Result flows back through Lua to the agent, with call logging for observability

Agents can also introspect available tools at runtime:

  • lua_read_doc("integrations.plausible") — Full API reference with parameter tables
  • lua_search_docs("query stats") — Search across all namespaces and supplementary docs
  • lua_list_docs() — List all available namespaces and static pages

Credential management in OpenCompany uses encrypted database storage instead of config files. The IntegrationSettingCredentialResolver reads from the integration_settings table (workspace-scoped, encrypted:array cast). Users configure credentials through the Integrations UI — tool packages are unaware of the storage backend.

Available Integrations

Package Tools Triggers Credentials Category Description
celestial 9 None Data Moon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup 34 4 API token Productivity Tasks, lists, folders, time tracking, docs, chat
coingecko 8 None Data Crypto prices, market data, trending coins, historical charts
constant-contact 6 Access token Email Contacts, campaigns, lists
etsy 6 API token E-commerce Shop listings, orders, inventory, seller profile
exchangerate 5 None Data 340+ currency conversions (fiat, crypto, metals)
google 117 OAuth Productivity Calendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid 1 None Rendering Flowcharts, sequences, Gantt, class diagrams → PNG
plantuml 1 None Rendering UML class, sequence, activity, component, state → PNG
microsoft-powerbi 6 Access token Analytics Reports, datasets, workspaces, user info
plausible 8 None Analytics Stats, realtime visitors, site and goal management
recruitee 6 Access token HR Job offers, candidates, departments, user info
splunk 6 Bearer token Monitoring Log search, indexes, saved searches, user context
statuspage 5 API key + Page ID Monitoring Incidents, components, status management
tapfiliate 5 API key Marketing Affiliates, conversions, referral tracking
ticktick 9 OAuth Productivity Projects, tasks, time tracking (TickTick and Dida365)
trustmrr 2 API key Data Verified startup revenue, MRR, growth, acquisitions
typst 1 None Rendering Reports, invoices, proposals → PDF
vegalite 1 None Rendering Bar, line, scatter, heatmap, boxplot charts → PNG
worldbank 6 None Data GDP, inflation, population for 200+ countries

Installation

Each package directory is an independent Composer package. In your consuming application:

{
    "repositories": [
        {"type": "path", "url": "../integrations/core"},
        {"type": "path", "url": "../integrations/packages/*"}
    ],
    "require": {
        "opencompanyapp/integration-core": "@dev",
        "opencompanyapp/integration-mermaid": "@dev",
        "opencompanyapp/integration-plausible": "@dev"
    }
}

Laravel auto-discovers service providers. For non-Laravel apps, use the contracts and registry directly.

System Dependencies

Some rendering integrations need external tools:

Package Dependency Install
mermaid mmdc (Mermaid CLI) npm install -g @mermaid-js/mermaid-cli
plantuml Java + plantuml.jar Bundled in plantuml/bin/, needs java on PATH
typst typst CLI brew install typst or typst.app
vegalite Node.js node on PATH; render script bundled in vegalite/bin/

Developer Guide

Building a New Integration

This walkthrough creates a complete integration from scratch. We'll build a "Weather" integration as an example.

1. Create the Package Directory

Create a new directory under packages/:

packages/weather/
├── composer.json
├── src/
│   ├── WeatherServiceProvider.php
│   ├── WeatherService.php
│   ├── WeatherToolProvider.php
│   └── Tools/
│       └── GetWeather.php
└── lua-docs/              (optional)
    └── weather.md

2. Define composer.json

{
    "name": "opencompanyapp/integration-weather",
    "description": "Weather data and forecasts integration for OpenCompany.",
    "license": "MIT",
    "authors": [
        {
            "name": "OpenCompany",
            "homepage": "https://github.com/OpenCompanyApp"
        }
    ],
    "keywords": ["tools", "weather", "forecasts", "opencompany"],
    "require": {
        "php": "^8.2",
        "opencompanyapp/integration-core": "^2.0 || @dev"
    },
    "autoload": {
        "psr-4": {
            "OpenCompany\\Integrations\\Weather\\": "src/"
        }
    },
    "extra": {
        "laravel": {
            "providers": [
                "OpenCompany\\Integrations\\Weather\\WeatherServiceProvider"
            ]
        }
    },
    "minimum-stability": "stable",
    "prefer-stable": true
}

Conventions:

  • Package name: opencompanyapp/integration-{name}
  • Namespace: OpenCompany\Integrations\{Name}\
  • If replacing an older standalone package, add a "replace" key: "opencompanyapp/ai-tool-weather": "self.version"
  • Only add illuminate/support to require if you use facades like Storage, Http, Log directly (most API integrations don't need it)

3. Create the Service Class

The service class encapsulates all API communication. Tools call the service — they never make HTTP requests directly.

<?php

namespace OpenCompany\Integrations\Weather;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class WeatherService
{
    private const BASE_URL = 'https://api.weather.example/v1';

    public function __construct(
        private string $apiKey = '',
    ) {}

    public function isConfigured(): bool
    {
        return ! empty($this->apiKey);
    }

    public function getCurrent(string $location): array
    {
        return $this->request('GET', '/current', [
            'location' => $location,
        ]);
    }

    public function getForecast(string $location, int $days = 3): array
    {
        return $this->request('GET', '/forecast', [
            'location' => $location,
            'days' => $days,
        ]);
    }

    private function request(string $method, string $path, array $params = []): array
    {
        if (! $this->isConfigured()) {
            throw new \RuntimeException('Weather API key is not configured.');
        }

        try {
            $response = Http::withHeaders([
                'Authorization' => "Bearer {$this->apiKey}",
                'Accept' => 'application/json',
            ])->timeout(15)->get(self::BASE_URL . $path, $params);

            if (! $response->successful()) {
                $error = $response->json('error') ?? $response->body();
                Log::error("Weather API error: {$method} {$path}", [
                    'status' => $response->status(),
                    'error' => $error,
                ]);
                throw new \RuntimeException(
                    'Weather API error (' . $response->status() . '): ' . $error
                );
            }

            return $response->json() ?? [];
        } catch (\Illuminate\Http\Client\ConnectionException $e) {
            throw new \RuntimeException("Failed to connect to Weather API: {$e->getMessage()}");
        }
    }
}

4. Create the Service Provider

The service provider wires everything into the Laravel container and registers with the ToolProviderRegistry.

<?php

namespace OpenCompany\Integrations\Weather;

use Illuminate\Support\ServiceProvider;
use OpenCompany\IntegrationCore\Contracts\CredentialResolver;
use OpenCompany\IntegrationCore\Support\ToolProviderRegistry;

class WeatherServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(WeatherService::class, function ($app) {
            $creds = $app->make(CredentialResolver::class);

            return new WeatherService(
                apiKey: $creds->get('weather', 'api_key', ''),
            );
        });
    }

    public function boot(): void
    {
        if ($this->app->bound(ToolProviderRegistry::class)) {
            $this->app->make(ToolProviderRegistry::class)
                ->register(new WeatherToolProvider());
        }
    }
}

Pattern notes:

  • Always register the service as a singleton — tools may be called multiple times in one request
  • Always check $this->app->bound(ToolProviderRegistry::class) before registering — the core package may not be installed
  • Use CredentialResolver to get API keys, never read config directly

5. Create the Tool Provider

The tool provider declares what tools are available and how to instantiate them.

<?php

namespace OpenCompany\Integrations\Weather;

use OpenCompany\IntegrationCore\Contracts\Tool;
use OpenCompany\IntegrationCore\Contracts\ToolProvider;
use OpenCompany\Integrations\Weather\Tools\GetWeather;
use OpenCompany\Integrations\Weather\Tools\GetForecast;

class WeatherToolProvider implements ToolProvider
{
    public function appName(): string
    {
        return 'weather';
    }

    public function appMeta(): array
    {
        return [
            'label'       => 'weather, forecasts, temperature',
            'description' => 'Weather data and forecasts',
            'icon'        => 'ph:cloud-sun',
            'logo'        => 'ph:cloud-sun',
        ];
    }

    public function tools(): array
    {
        return [
            'get_weather' => [
                'class'       => GetWeather::class,
                'type'        => 'read',
                'name'        => 'Get Weather',
                'description' => 'Current weather for any location.',
                'icon'        => 'ph:cloud-sun',
            ],
            'get_forecast' => [
                'class'       => GetForecast::class,
                'type'        => 'read',
                'name'        => 'Get Forecast',
                'description' => 'Multi-day weather forecast.',
                'icon'        => 'ph:calendar',
            ],
        ];
    }

    public function isIntegration(): bool
    {
        return true;
    }

    public function createTool(string $class, array $context = []): Tool
    {
        return new $class(app(WeatherService::class));
    }

    public function luaDocsPath(): ?string
    {
        return __DIR__ . '/../lua-docs/weather.md';
    }

    public function credentialFields(): array
    {
        return [
            [
                'key'         => 'api_key',
                'type'        => 'secret',
                'label'       => 'API Key',
                'required'    => true,
                'placeholder' => 'wth_...',
            ],
        ];
    }
}

tools() array keys:

  • class — Fully-qualified class name of the Tool implementation
  • type'read' (fetches data) or 'write' (creates/modifies/deletes)
  • name — Human-readable display name
  • description — Short description for listings and UI cards
  • iconIconify identifier (we use the ph: Phosphor set)

createTool() context:

  • The $context array is injected by the host application at runtime
  • In OpenCompany: ['agent' => User, 'timezone' => 'Europe/Amsterdam']
  • In KosmoKrator: ['account' => 'default']
  • Use it to pass runtime dependencies without coupling to specific models

6. Create Tool Classes

Each tool is a single callable action.

<?php

namespace OpenCompany\Integrations\Weather\Tools;

use OpenCompany\IntegrationCore\Contracts\Tool;
use OpenCompany\IntegrationCore\Support\ToolResult;
use OpenCompany\Integrations\Weather\WeatherService;

class GetWeather implements Tool
{
    public function __construct(
        private WeatherService $service,
    ) {}

    public function name(): string
    {
        return 'get_weather';
    }

    public function description(): string
    {
        return 'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
    }

    public function parameters(): array
    {
        return [
            'location' => [
                'type'        => 'string',
                'required'    => true,
                'description' => 'City name, address, or coordinates (e.g. "Amsterdam", "51.5,-0.1").',
            ],
            'units' => [
                'type'        => 'string',
                'enum'        => ['metric', 'imperial'],
                'description' => 'Unit system (default: metric).',
            ],
        ];
    }

    public function execute(array $args): ToolResult
    {
        $location = $args['location'] ?? '';
        if (empty($location)) {
            return ToolResult::error('Location is required.');
        }

        try {
            $data = $this->service->getCurrent($location);

            return ToolResult::success($data);
        } catch (\Throwable $e) {
            return ToolResult::error($e->getMessage());
        }
    }
}

Parameter types: string, integer, number, boolean, array, object

Optional parameter keys:

  • requiredtrue if the parameter must be provided (default false)
  • description — Shown in generated Lua docs and tool catalogs
  • enum — Array of allowed string values
  • items — Element type for arrays, e.g. ['type' => 'string']
  • properties — Sub-property definitions for objects
  • default — Default value if not provided

ToolResult patterns:

// Success with data (array or string)
return ToolResult::success(['temperature' => 22, 'unit' => 'C']);
return ToolResult::success('The current temperature is 22C.');

// Success with metadata (files created, timing info, etc.)
return ToolResult::success($data, ['files' => [$fileInfo]]);

// Error
return ToolResult::error('Location not found.');

Integration Types

The codebase has four distinct integration patterns. Pick the one that matches your use case.

Type A: Public API (No Credentials)

For APIs that don't require authentication: exchangerate, worldbank, coingecko, celestial.

// ToolProvider
public function credentialFields(): array
{
    return []; // No credentials needed
}

// ServiceProvider — no credential resolver needed
public function register(): void
{
    $this->app->singleton(MyService::class);
}

Type B: API Key Authentication

For services that need an API key: plausible, trustmrr.

// ServiceProvider — inject credentials
$this->app->singleton(MyService::class, function ($app) {
    $creds = $app->make(CredentialResolver::class);

    return new MyService(
        apiKey: $creds->get('myservice', 'api_key', ''),
        baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
    );
});

// ToolProvider
public function credentialFields(): array
{
    return [
        ['key' => 'api_key', 'type' => 'secret', 'label' => 'API Key', 'required' => true],
        ['key' => 'url', 'type' => 'url', 'label' => 'Base URL', 'default' => 'https://api.example.com'],
    ];
}

Type C: OAuth Authentication

For services requiring OAuth flows: clickup, ticktick, google.

These integrations register OAuth routes in their service provider and include a controller:

// ServiceProvider boot()
Route::prefix('api/integrations/myservice/oauth')->group(function () {
    Route::get('authorize', [MyOAuthController::class, 'authorize']);
    Route::get('callback', [MyOAuthController::class, 'callback']);
});

// ToolProvider credentialFields
public function credentialFields(): array
{
    return [
        ['key' => 'client_id', 'type' => 'string', 'label' => 'Client ID', 'required' => true],
        ['key' => 'client_secret', 'type' => 'secret', 'label' => 'Client Secret', 'required' => true],
        ['key' => 'access_token', 'type' => 'oauth', 'label' => 'Connect Account'],
    ];
}

Type D: Rendering / File Output

For tools that produce files (images, PDFs): mermaid, plantuml, typst, vegalite.

These use the AgentFileStorage contract to save output files:

// ToolProvider — inject file storage
public function createTool(string $class, array $context = []): Tool
{
    $fileStorage = app()->bound(AgentFileStorage::class)
        ? app(AgentFileStorage::class)
        : null;

    return new $class(
        app(MyRenderService::class),
        $fileStorage,
        $context['agent'] ?? null,
    );
}

// Tool — use file storage if available, fall back to public disk
public function execute(array $args): ToolResult
{
    $bytes = $this->service->renderToBytes($input);

    if ($this->fileStorage && $this->agent) {
        $result = $this->fileStorage->saveFile(
            $this->agent, 'output.png', $bytes, 'image/png', 'myrenderer'
        );
        return ToolResult::success("![Title]({$result['url']})");
    }

    $url = $this->service->render($input); // saves to public disk
    return ToolResult::success("![Title]({$url})");
}

Multi-Account Support

Integrations and MCP servers support multiple credential sets per workspace. Users can connect several accounts for the same service (e.g., "work" and "personal" ClickUp workspaces, two GitHub MCP servers) and agents can target any of them.

How It Works

Single account (default): Flat namespace, backward compatible.

app.integrations.clickup.create_task({ list_id = "123", name = "Ship it" })

Portable scripts: Use .default to always target the user's default account — works regardless of how many accounts exist. This is the recommended pattern for shareable scripts and automations.

app.integrations.clickup.default.create_task({ list_id = "123", name = "Ship it" })
app.mcp.github.default.search_repos({ query = "bug" })

Multiple accounts: Per-account sub-namespaces appear alongside the flat and default namespaces.

-- Uses the default account
app.integrations.clickup.create_task({ list_id = "123", name = "Ship it" })
app.integrations.clickup.default.create_task({ list_id = "123", name = "Ship it" })

-- Explicit account targeting
app.integrations.clickup.work.create_task({ list_id = "123", name = "Ship it" })
app.integrations.clickup.personal.create_task({ list_id = "456", name = "Buy groceries" })

-- MCP servers work the same way
app.mcp.github.work.search_repos({ query = "internal" })
app.mcp.github.personal.search_repos({ query = "side-project" })

Agents discover available accounts via lua_read_doc("integrations.clickup") or lua_read_doc("mcp.github") — each account appears as a separate sub-namespace with the same functions.

Implementation in Tool Providers

The $context['account'] parameter is passed through to createTool(). When set, resolve credentials for that specific account:

public function createTool(string $class, array $context = []): Tool
{
    $account = $context['account'] ?? null;

    if ($account !== null) {
        $creds = app(CredentialResolver::class);
        $service = new MyService(
            apiKey: $creds->get('myservice', 'api_key', '', $account),
        );
        return new $class($service);
    }

    // Default: use the container singleton (single-account path)
    return new $class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

Column Type Description
account_alias VARCHAR(32) '' = default account, 'work' / 'personal' = named accounts
is_default BOOLEAN Which named account the flat namespace resolves to (integration_settings only)

Unique constraints: (workspace_id, integration_id, account_alias) and (workspace_id, slug, account_alias).

MCP servers sharing the same slug but different account aliases are grouped into a single provider. The default account's server provides the canonical tool definitions.

API Endpoints

Method Path Description
GET /api/integrations/{id}/accounts List all accounts
POST /api/integrations/{id}/accounts Create a new account (requires alias + config)
PUT /api/integrations/{id}/accounts/{alias} Update account config
DELETE /api/integrations/{id}/accounts/{alias} Remove an account
POST /api/integrations/{id}/accounts/{alias}/default Set as default

Triggers

Triggers are event sources — they receive events from external services (via webhook) or discover new events (via polling). While tools are pull (agent calls a function), triggers are push (external service sends data to us).

The integration repo defines triggers declaratively; the host application provides infrastructure (HTTP endpoints, job scheduling, state persistence).

Trigger Types

Type How It Works Example
Webhook External service POSTs events to a host-generated URL ClickUp fires taskCreated to your endpoint
Polling Host periodically calls poll() to check for new data Check an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

use OpenCompany\IntegrationCore\Contracts\HasTriggers;
use OpenCompany\IntegrationCore\Contracts\Trigger;

class ClickUpToolProvider implements ToolProvider, HasTriggers
{
    public function triggers(): array
    {
        return [
            'clickup_task_created' => [
                'class' => ClickUpTaskCreatedTrigger::class,
                'name' => 'Task Created',
                'description' => 'Triggered when a new task is created.',
                'icon' => 'ph:plus-circle',
            ],
        ];
    }

    public function createTrigger(string $class, array $context = []): Trigger
    {
        return new $class($this->resolveService($context));
    }
}

Building a Webhook Trigger

use OpenCompany\IntegrationCore\Contracts\Trigger;
use OpenCompany\IntegrationCore\Contracts\TriggerContext;
use OpenCompany\IntegrationCore\Support\TriggerResult;
use OpenCompany\IntegrationCore\Support\TriggerType;

class ClickUpTaskCreatedTrigger extends Trigger
{
    public function __construct(protected ClickUpService $service) {}

    public function name(): string { return 'clickup_task_created'; }
    public function description(): string { return 'Triggered when a task is created.'; }
    public function type(): TriggerType { return TriggerType::Webhook; }

    public function parameters(): array
    {
        return [
            'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
        ];
    }

    public function onEnable(TriggerContext $ctx): void
    {
        $response = $this->service->createWebhook($this->service->getWorkspaceId(), [
            'endpoint' => $ctx->webhookUrl(),
            'events' => ['taskCreated'],
        ]);
        $ctx->store()->put('webhook_id', $response['webhook']['id']);
        $ctx->store()->put('webhook_secret', $response['webhook']['secret']);
    }

    public function onDisable(TriggerContext $ctx): void
    {
        $this->service->deleteWebhook($ctx->store()->get('webhook_id'));
        $ctx->store()->forget('webhook_id');
        $ctx->store()->forget('webhook_secret');
    }

    public function verify(TriggerContext $ctx, array $headers, string $rawBody): bool
    {
        $secret = $ctx->store()->get('webhook_secret', '');
        $expected = hash_hmac('sha256', $rawBody, $secret);
        return hash_equals($expected, $headers['x-signature'] ?? '');
    }

    public function process(TriggerContext $ctx, array $payload): TriggerResult
    {
        return TriggerResult::event([
            'event' => 'taskCreated',
            'task' => $this->service->getTask($payload['task_id']),
        ]);
    }
}

Building a Polling Trigger

class ExchangeRateChangedTrigger extends Trigger
{
    public function type(): TriggerType { return TriggerType::Polling; }

    public function onEnable(TriggerContext $ctx): void
    {
        // Store baseline for comparison
        $ctx->store()->put('last_rates', $this->service->getRates());
    }

    public function onDisable(TriggerContext $ctx): void
    {
        $ctx->store()->forget('last_rates');
    }

    public function poll(TriggerContext $ctx): TriggerResult
    {
        $current = $this->service->getRates();
        $previous = $ctx->store()->get('last_rates', []);
        $ctx->store()->put('last_rates', $current);

        $changed = array_filter($current, fn ($rate, $key) =>
            ($previous[$key] ?? null) !== $rate, ARRAY_FILTER_USE_BOTH);

        return $changed ? TriggerResult::event($changed) : TriggerResult::empty();
    }
}

How the Host Uses Triggers

The host discovers triggers through the same ToolProviderRegistry:

// Discovery
foreach ($registry->all() as $provider) {
    if ($provider instanceof HasTriggers) {
        foreach ($provider->triggers() as $slug => $meta) {
            // Register webhook routes, build trigger catalog for UI
        }
    }
}

// Enable a trigger
$trigger = $provider->createTrigger($meta['class'], ['account' => $account]);
$trigger->onEnable($context);  // Registers webhook at external service

// Incoming webhook request
$handshake = $trigger->handshake($payload);
if ($handshake !== null) {
    return response()->json($handshake);  // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
    $result = $trigger->process($context, json_decode($rawBody, true));
    foreach ($result->events as $event) {
        // Dispatch to automations, notify agents, etc.
    }
}

// Disable
$trigger->onDisable($context);  // Deregisters webhook

Trigger Contracts

Contract Type Purpose
Trigger Abstract class Base for all triggers — lifecycle, processing, verification
TriggerContext Interface Host-provided: webhook URL, store, config
TriggerStore Interface Host-provided: key-value persistence per subscription
TriggerResult Value object Wraps zero or more events from process/poll
TriggerType Enum Webhook or Polling
HasTriggers Interface Optional interface for trigger-capable providers

Making an Integration Configurable

To add a settings UI in OpenCompany, implement ConfigurableIntegration alongside ToolProvider:

use OpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
use OpenCompany\IntegrationCore\Contracts\ToolProvider;

class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
    // ... ToolProvider methods ...

    public function integrationMeta(): array
    {
        return [
            'name'        => 'Weather',
            'description' => 'Weather data and forecasts for any location',
            'icon'        => 'ph:cloud-sun',
            'logo'        => 'ph:cloud-sun',
            'category'    => 'data',          // data, productivity, analytics, rendering
            'badge'       => 'New',           // optional badge text
            'docs_url'    => 'https://...',   // optional external docs link
        ];
    }

    public function configSchema(): array
    {
        return [
            [
                'key'         => 'api_key',
                'type'        => 'secret',
                'label'       => 'API Key',
                'placeholder' => 'wth_...',
                'hint'        => 'Get your key at <a href="https://weather.example/keys" target="_blank">weather.example</a>.',
                'required'    => true,
            ],
            [
                'key'     => 'units',
                'type'    => 'select',
                'label'   => 'Default Units',
                'options' => ['metric' => 'Metric (C, km/h)', 'imperial' => 'Imperial (F, mph)'],
                'default' => 'metric',
            ],
        ];
    }

    public function testConnection(array $config): array
    {
        try {
            // Make a lightweight API call to verify credentials
            $response = Http::withHeaders([
                'Authorization' => "Bearer {$config['api_key']}",
            ])->timeout(10)->get('https://api.weather.example/v1/ping');

            if ($response->successful()) {
                return ['success' => true, 'message' => 'Connected to Weather API.'];
            }

            return ['success' => false, 'error' => 'Invalid API key.'];
        } catch (\Exception $e) {
            return ['success' => false, 'error' => $e->getMessage()];
        }
    }

    public function validationRules(): array
    {
        return [
            'api_key' => 'nullable|string',
            'units' => 'nullable|in:metric,imperial',
        ];
    }
}

Config field types:

  • secret — Masked input, stored encrypted
  • text / string — Plain text input
  • url — URL input with format validation
  • select — Dropdown, requires options array
  • string_list — Dynamic list of strings (e.g. site IDs)
  • oauth_connect — OAuth connection button, requires authorize_url and redirect_uri

Conditional fields — Show a field only when another field has a specific value:

[
    'key' => 'workspace_id',
    'type' => 'text',
    'label' => 'Workspace ID',
    'visible_when' => ['field' => 'mode', 'value' => 'workspace'],
]

Lua Documentation

Agents discover tools through auto-generated Lua API docs. The LuaDocRenderer and LuaCatalogBuilder in core handle this automatically based on your parameters() and description() definitions.

For complex integrations, add a lua-docs/{name}.md file with supplementary documentation — workflows, examples, and gotchas that aren't captured by the parameter reference.

How Lua Routing Works

The LuaCatalogBuilder transforms your tool definitions into a Lua namespace tree:

app.integrations.weather.get({location = "Amsterdam"})
│   │              │      │
│   │              │      └─ Function name (derived from tool name, minus app name)
│   │              └─ App name (from ToolProvider::appName())
│   └─ "integrations." prefix (added when isIntegration() returns true)
└─ Root namespace

Function name derivationLuaCatalogBuilder::deriveFunctionName() converts the tool's name field (not the slug) to a Lua-friendly function name:

  1. Converts to snake_case
  2. Removes stop words (on, of, for, in, to, the, a, an)
  3. Removes words that overlap with the app name (e.g. "Exchange Rates" in the exchangerate app → exchange_rates)
  4. Falls back to the full snake_case name if filtering removes everything

For example, with appName() = 'google_sheets':

  • "Create Spreadsheet" → create_spreadsheet
  • "Add Sheet" → add (because "sheet" overlaps with "google_sheets")
  • "Write Range" → write_range

The LuaBridge then:

  1. Looks up the function path in its functionMap to find the tool slug
  2. Maps positional arguments to named parameters via parameterMap
  3. Delegates to LuaToolInvoker::invoke() which instantiates and executes the tool
  4. Logs the call (path, duration, status, error) for observability
  5. Suggests similar functions on typos ("Did you mean: ...")

Writing Lua Docs

Supplementary docs are appended below the auto-generated parameter reference when an agent calls lua_read_doc("integrations.{name}"). Use the correct app.integrations.* calling convention — agents will copy-paste from these examples:

## Common Workflows

### Get current weather and format it

```lua
local weather = app.integrations.weather.get({location = "Amsterdam"})
local forecast = app.integrations.weather.forecast({location = "Amsterdam", days = 3})

Notes

  • Locations accept city names, addresses, or lat/lng coordinates
  • Rate limit: 60 requests per minute

Use the **derived function names** (as shown in auto-generated docs), not the raw tool slugs. For example, write `app.integrations.coingecko.market_rankings()` not `coingecko_markets()`.

Point to the file in your tool provider:

```php
public function luaDocsPath(): ?string
{
    return __DIR__ . '/../lua-docs/weather.md';
}

Core Contracts Reference

Tool

The fundamental unit of work. Every tool implements this interface.

interface Tool
{
    public function name(): string;           // Slug for routing (e.g. 'get_weather')
    public function description(): string;    // Shown in docs and catalogs
    public function parameters(): array;      // Parameter definitions
    public function execute(array $args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
    public function appName(): string;                              // Unique identifier
    public function appMeta(): array;                               // UI metadata
    public function tools(): array;                                 // Tool definitions
    public function isIntegration(): bool;                          // Toggleable per agent?
    public function createTool(string $class, array $context = []): Tool;
    public function luaDocsPath(): ?string;                         // Supplementary docs
    public function credentialFields(): array;                      // Required credentials
}

CredentialResolver

Abstracts credential storage. The host application binds its own implementation.

interface CredentialResolver
{
    public function get(string $integration, string $key, mixed $default = null, ?string $account = null): mixed;
    public function isConfigured(string $integration, ?string $account = null): bool;
}

The $account parameter supports multi-account setups (e.g. "work" and "personal" Google accounts).

ConfigurableIntegration

Optional. Adds a settings UI for the integration in OpenCompany.

interface ConfigurableIntegration
{
    public function integrationMeta(): array;       // Name, description, icon, category
    public function configSchema(): array;          // Form field definitions
    public function testConnection(array $config): array;  // Verify credentials
    public function validationRules(): array;       // Laravel validation rules
}

AgentFileStorage

Allows tools to save files into the agent's workspace without coupling to the host's file system.

interface AgentFileStorage
{
    public function saveFile(
        object $agent,
        string $filename,
        string $content,
        string $mimeType,
        ?string $subfolder = null,
    ): array; // Returns ['id' => ..., 'path' => ..., 'url' => ...]
}

LuaToolInvoker

Host-side adapter for executing tools from the Lua bridge.

interface LuaToolInvoker
{
    public function invoke(string $toolSlug, array $args): mixed;
    public function getToolMeta(string $toolSlug): array;
}

ToolResult

Value object returned by all tool executions.

$result = ToolResult::success($data);            // Success with data
$result = ToolResult::success($data, $meta);     // Success with metadata
$result = ToolResult::error('Something failed'); // Error

$result->succeeded();  // bool
$result->data;         // mixed — string, array, or any serializable value
$result->error;        // ?string
$result->meta;         // array — files, timing, etc.
$result->toString();   // String representation for legacy consumers

HasTriggers

Optional. Adds trigger/webhook support to a ToolProvider.

interface HasTriggers
{
    public function triggers(): array;   // Slug => {class, name, description, icon}
    public function createTrigger(string $class, array $context = []): Trigger;
}

Trigger

Abstract base class for event sources. Webhook triggers override process() and verify(); polling triggers override poll().

abstract class Trigger
{
    abstract public function name(): string;
    abstract public function description(): string;
    abstract public function type(): TriggerType;        // Webhook or Polling
    abstract public function onEnable(TriggerContext $ctx): void;
    abstract public function onDisable(TriggerContext $ctx): void;

    public function parameters(): array;                  // Config fields (default: [])
    public function process(TriggerContext $ctx, array $payload): TriggerResult;
    public function poll(TriggerContext $ctx): TriggerResult;
    public function verify(TriggerContext $ctx, array $headers, string $rawBody): bool;
    public function handshake(array $payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
    public function webhookUrl(): string;   // Host-generated endpoint URL
    public function store(): TriggerStore;  // Persistent key-value storage
    public function config(): array;        // User configuration values
}

interface TriggerStore
{
    public function get(string $key, mixed $default = null): mixed;
    public function put(string $key, mixed $value): void;
    public function has(string $key): bool;
    public function forget(string $key): void;
}

TriggerResult

Value object returned by process() and poll().

$result = TriggerResult::event($data);   // Single event
$result = TriggerResult::from($events);  // Multiple events
$result = TriggerResult::empty();        // No events

$result->hasEvents();  // bool
$result->count();      // int
$result->events;       // list<array>
$result->meta;         // array

Credential Management

For Standalone Laravel Apps

The default ConfigCredentialResolver reads from config/ai-tools.php:

// config/ai-tools.php
return [
    'weather' => [
        'api_key' => env('WEATHER_API_KEY'),
    ],
    'plausible' => [
        'api_key' => env('PLAUSIBLE_API_KEY'),
        'url'     => env('PLAUSIBLE_URL', 'https://plausible.io'),
    ],
    // Multi-account example
    'gmail' => [
        'work'     => ['api_key' => env('GMAIL_WORK_KEY')],
        'personal' => ['api_key' => env('GMAIL_PERSONAL_KEY')],
    ],
];

How OpenCompany Manages Credentials

OpenCompany replaces ConfigCredentialResolver with IntegrationSettingCredentialResolver — a database-backed implementation:

  • Storage: integration_settings table with an encrypted:array config column (Laravel's encryption cast)
  • Scoping: All queries are workspace-scoped via BelongsToWorkspace trait — credentials never leak between workspaces
  • UI: Users configure credentials through the Integrations settings page. Packages that implement ConfigurableIntegration get automatic form rendering from their configSchema()
  • Masking: Secret fields are never returned in plaintext to the frontend — displayed as ****xxxx
  • Test connection: The UI calls testConnection() to verify credentials before saving
// OpenCompany's AppServiceProvider
$this->app->singleton(
    CredentialResolver::class,
    IntegrationSettingCredentialResolver::class,
);

The $account parameter on CredentialResolver::get() is defined for future multi-account support but currently unused in OpenCompany (workspace scoping serves as the default account boundary).

Custom Credential Storage

Bind your own CredentialResolver implementation:

// In your AppServiceProvider
$this->app->singleton(
    \OpenCompany\IntegrationCore\Contracts\CredentialResolver::class,
    \App\Services\YourCustomResolver::class,
);

Static Analysis

Packages that include a phpstan.neon are configured for Larastan level 5:

includes:
    - vendor/larastan/larastan/extension.neon

parameters:
    paths:
        - src/
    level: 5

Run from any package directory:

cd packages/mermaid && ../../vendor/bin/phpstan analyse

Contributing

Adding a New Integration

  1. Create a new directory under packages/ following the structure above
  2. Implement ToolProvider (and optionally ConfigurableIntegration)
  3. Create your service class and tool classes
  4. Add lua-docs if the integration has non-obvious workflows — use app.integrations.{name}.{function}() syntax
  5. Add a phpstan.neon and ensure level 5 passes
  6. Update this README's structure listing and integrations table

Conventions

  • Naming: Package directories and appName() are lowercase kebab/snake. Namespaces are PascalCase.
  • Icons: Use Phosphor Icons (ph: prefix).
  • Tool types: Use 'read' for tools that fetch data, 'write' for tools that create, modify, or delete.
  • Parameter names: Always snake_case.
  • Error handling: Tools should catch exceptions and return ToolResult::error() — never let exceptions bubble out of execute().
  • Service isolation: Tools call service methods. Services make HTTP requests. Tools never make HTTP requests directly.
  • No hardcoded config: Always use CredentialResolver for API keys and endpoints. Never read config() or env() directly in tool or service classes.

Checklist for New Integrations

  • composer.json with correct package name, namespace, and Laravel provider auto-discovery
  • Service class encapsulating all API communication
  • Service provider with singleton service registration and ToolProviderRegistry boot
  • Tool provider implementing ToolProvider (and ConfigurableIntegration if credentials are needed)
  • Tool classes with clear description(), typed parameters(), and ToolResult returns
  • credentialFields() defined for any required API keys or tokens
  • testConnection() if implementing ConfigurableIntegration
  • lua-docs/{name}.md for integrations with complex workflows (using app.integrations.* calling convention)
  • Entry added to README structure listing and integrations table
  • Lua-doc function names match deriveFunctionName() output (check auto-generated docs via lua_read_doc)

License

MIT

About

OpenCompany integration packages monorepo

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages