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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

PackageToolsTriggersCredentialsCategoryDescription
celestial9NoneDataMoon phases, sunrise/sunset, planet positions, eclipses, zodiac
clickup344API tokenProductivityTasks, lists, folders, time tracking, docs, chat
coingecko8NoneDataCrypto prices, market data, trending coins, historical charts
constant-contact6Access tokenEmailContacts, campaigns, lists
etsy6API tokenE-commerceShop listings, orders, inventory, seller profile
exchangerate5NoneData340+ currency conversions (fiat, crypto, metals)
google117OAuthProductivityCalendar, Gmail, Drive, Sheets, Docs, Forms, Contacts, Tasks, Analytics, Search Console
mermaid1NoneRenderingFlowcharts, sequences, Gantt, class diagrams → PNG
plantuml1NoneRenderingUML class, sequence, activity, component, state → PNG
microsoft-powerbi6Access tokenAnalyticsReports, datasets, workspaces, user info
plausible8NoneAnalyticsStats, realtime visitors, site and goal management
recruitee6Access tokenHRJob offers, candidates, departments, user info
splunk6Bearer tokenMonitoringLog search, indexes, saved searches, user context
statuspage5API key + Page IDMonitoringIncidents, components, status management
tapfiliate5API keyMarketingAffiliates, conversions, referral tracking
ticktick9OAuthProductivityProjects, tasks, time tracking (TickTick and Dida365)
trustmrr2API keyDataVerified startup revenue, MRR, growth, acquisitions
typst1NoneRenderingReports, invoices, proposals → PDF
vegalite1NoneRenderingBar, line, scatter, heatmap, boxplot charts → PNG
worldbank6NoneDataGDP, 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.

Catalog and SEO Metadata

php build-catalog.php writes integrations-catalog.json, the machine-readable catalog used by KosmoKrator docs, headless CLI discovery, Lua API docs, and SEO pages. Every integration stays in the catalog, including integrations that are not fully supported by a local CLI runtime yet, so hosts can document future proxy support without hiding available packages.

The catalog includes:

  • auth, auth_strategy, and auth_summary
  • host_availability for CLI, web, proxy, and MCP gateway surfaces
  • runtime_requirements for binaries or services such as mmdc, Java, Typst, or Node.js
  • compatibility, compatibility_summary, cli_setup_supported, and cli_runtime_supported
  • setup with generated headless configure, doctor, status, and MCP gateway commands
  • seo with title, meta description, keyword phrases, setup summaries, and tool counts

Most packages do not need explicit metadata. The catalog builder derives sensible defaults from credentialFields(), tool read/write types, package metadata, and Lua docs. For example, a ClickUp package with api_token and workspace_id credentials gets generated setup instructions like:

kosmokrator integrations:configure clickup --set api_token="$CLICKUP_API_TOKEN" --set workspace_id="$CLICKUP_WORKSPACE_ID" --enable --read allow --write ask --jsonkosmokrator integrations:doctor clickup --jsonkosmokrator mcp:serve --integration=clickup --write=deny

When inference is not specific enough, implement HasIntegrationCapabilities on the provider or add the same keys to appMeta() / integrationMeta():

useOpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities;
class AcmeToolProvider implements ToolProvider, HasIntegrationCapabilities
{
publicfunctionintegrationCapabilities(): array
{
return [
'auth_strategy' => 'oauth2_authorization_code',
'cli_setup_supported' => false,
'cli_runtime_supported' => true,
'host_availability' => [
'cli' => true,
'web' => true,
'proxy' => true,
'mcp_gateway' => true,
],
'runtime_requirements' => [
['name' => 'acme', 'type' => 'binary', 'required' => true],
],
'seo' => [
'cli_setup_summary' => 'Acme can run from KosmoKrator after credentials are connected through OAuth.',
'mcp_setup_summary' => 'Expose Acme tools to MCP clients through the KosmoKrator MCP gateway.',
],
];
}
}

Use cli_setup_supported: false when credentials cannot be configured fully headlessly, for example browser redirect OAuth without device-code or manual-token support. Use cli_runtime_supported: false only when the tool cannot currently run locally. The docs site should still render those integrations and explain the limitation.

System Dependencies

Some rendering integrations need external tools:

PackageDependencyInstall
mermaidmmdc (Mermaid CLI)npm install -g @mermaid-js/mermaid-cli
plantumlJava + plantuml.jarBundled in plantuml/bin/, needs java on PATH
typsttypst CLIbrew install typst or typst.app
vegaliteNode.jsnode 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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\Facades\Http;
useIlluminate\Support\Facades\Log;
class WeatherService
{
privateconstBASE_URL = 'https://api.weather.example/v1';
publicfunction__construct(
privatestring$apiKey = '',
) {}
publicfunctionisConfigured(): bool
{
return ! empty($this->apiKey);
}
publicfunctiongetCurrent(string$location): array
{
return$this->request('GET', '/current', [
'location' => $location,
]);
}
publicfunctiongetForecast(string$location, int$days = 3): array
{
return$this->request('GET', '/forecast', [
'location' => $location,
'days' => $days,
]);
}
privatefunctionrequest(string$method, string$path, array$params = []): array
{
if (! $this->isConfigured()) {
thrownew \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,
]);
thrownew \RuntimeException(
'Weather API error (' . $response->status() . '): ' . $error
);
}
return$response->json() ?? [];
} catch (\Illuminate\Http\Client\ConnectionException$e) {
thrownew \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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useIlluminate\Support\ServiceProvider;
useOpenCompany\IntegrationCore\Contracts\CredentialResolver;
useOpenCompany\IntegrationCore\Support\ToolProviderRegistry;
class WeatherServiceProvider extends ServiceProvider
{
publicfunctionregister(): void
{
$this->app->singleton(WeatherService::class, function ($app) {
$creds = $app->make(CredentialResolver::class);
returnnewWeatherService(
apiKey: $creds->get('weather', 'api_key', ''),
);
});
}
publicfunctionboot(): void
{
if ($this->app->bound(ToolProviderRegistry::class)) {
$this->app->make(ToolProviderRegistry::class)
->register(newWeatherToolProvider());
}
}
}

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.

<?phpnamespaceOpenCompany\Integrations\Weather;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
useOpenCompany\Integrations\Weather\Tools\GetWeather;
useOpenCompany\Integrations\Weather\Tools\GetForecast;
class WeatherToolProvider implements ToolProvider
{
publicfunctionappName(): string
{
return'weather';
}
publicfunctionappMeta(): array
{
return [
'label' => 'weather, forecasts, temperature',
'description' => 'Weather data and forecasts',
'icon' => 'ph:cloud-sun',
'logo' => 'ph:cloud-sun',
];
}
publicfunctiontools(): 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',
],
];
}
publicfunctionisIntegration(): bool
{
returntrue;
}
publicfunctioncreateTool(string$class, array$context = []): Tool
{
returnnew$class(app(WeatherService::class));
}
publicfunctionluaDocsPath(): ?string
{
return__DIR__ . '/../lua-docs/weather.md';
}
publicfunctioncredentialFields(): 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.

<?phpnamespaceOpenCompany\Integrations\Weather\Tools;
useOpenCompany\IntegrationCore\Contracts\Tool;
useOpenCompany\IntegrationCore\Support\ToolResult;
useOpenCompany\Integrations\Weather\WeatherService;
class GetWeather implements Tool
{
publicfunction__construct(
privateWeatherService$service,
) {}
publicfunctionname(): string
{
return'get_weather';
}
publicfunctiondescription(): string
{
return'Get current weather conditions for any location. Returns temperature, humidity, wind speed, and conditions.';
}
publicfunctionparameters(): 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).',
],
];
}
publicfunctionexecute(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]]);
// Errorreturn 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.

// ToolProviderpublicfunctioncredentialFields(): array
{
return []; // No credentials needed
}
// ServiceProvider — no credential resolver neededpublicfunctionregister(): 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);
returnnewMyService(
apiKey: $creds->get('myservice', 'api_key', ''),
baseUrl: $creds->get('myservice', 'url', 'https://api.example.com'),
);
});
// ToolProviderpublicfunctioncredentialFields(): 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 credentialFieldspublicfunctioncredentialFields(): 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 storagepublicfunctioncreateTool(string$class, array$context = []): Tool
{
$fileStorage = app()->bound(AgentFileStorage::class)
? app(AgentFileStorage::class)
: null;
returnnew$class(
app(MyRenderService::class),
$fileStorage,
$context['agent'] ?? null,
);
}
// Tool — use file storage if available, fall back to public diskpublicfunctionexecute(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 diskreturn 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 accountapp.integrations.clickup.create_task({ list_id="123", name="Ship it" })
app.integrations.clickup.default.create_task({ list_id="123", name="Ship it" })
-- Explicit account targetingapp.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 wayapp.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:

publicfunctioncreateTool(string$class, array$context = []): Tool
{
$account = $context['account'] ?? null;
if ($account !== null) {
$creds = app(CredentialResolver::class);
$service = newMyService(
apiKey: $creds->get('myservice', 'api_key', '', $account),
);
returnnew$class($service);
}
// Default: use the container singleton (single-account path)returnnew$class(app(MyService::class));
}

Database Schema

Both integration_settings and mcp_servers use account_alias to differentiate accounts:

ColumnTypeDescription
account_aliasVARCHAR(32)'' = default account, 'work' / 'personal' = named accounts
is_defaultBOOLEANWhich 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

MethodPathDescription
GET/api/integrations/{id}/accountsList all accounts
POST/api/integrations/{id}/accountsCreate 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}/defaultSet 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

TypeHow It WorksExample
WebhookExternal service POSTs events to a host-generated URLClickUp fires taskCreated to your endpoint
PollingHost periodically calls poll() to check for new dataCheck an API every 5 min for changes

Adding Triggers to an Integration

Implement HasTriggers alongside your existing ToolProvider:

useOpenCompany\IntegrationCore\Contracts\HasTriggers;
useOpenCompany\IntegrationCore\Contracts\Trigger;
class ClickUpToolProvider implements ToolProvider, HasTriggers
{
publicfunctiontriggers(): array
{
return [
'clickup_task_created' => [
'class' => ClickUpTaskCreatedTrigger::class,
'name' => 'Task Created',
'description' => 'Triggered when a new task is created.',
'icon' => 'ph:plus-circle',
],
];
}
publicfunctioncreateTrigger(string$class, array$context = []): Trigger
{
returnnew$class($this->resolveService($context));
}
}

Building a Webhook Trigger

useOpenCompany\IntegrationCore\Contracts\Trigger;
useOpenCompany\IntegrationCore\Contracts\TriggerContext;
useOpenCompany\IntegrationCore\Support\TriggerResult;
useOpenCompany\IntegrationCore\Support\TriggerType;
class ClickUpTaskCreatedTrigger extends Trigger
{
publicfunction__construct(protectedClickUpService$service) {}
publicfunctionname(): string { return'clickup_task_created'; }
publicfunctiondescription(): string { return'Triggered when a task is created.'; }
publicfunctiontype(): TriggerType { return TriggerType::Webhook; }
publicfunctionparameters(): array
{
return [
'space_id' => ['type' => 'string', 'description' => 'Scope to a space (optional).'],
];
}
publicfunctiononEnable(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']);
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$this->service->deleteWebhook($ctx->store()->get('webhook_id'));
$ctx->store()->forget('webhook_id');
$ctx->store()->forget('webhook_secret');
}
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool
{
$secret = $ctx->store()->get('webhook_secret', '');
$expected = hash_hmac('sha256', $rawBody, $secret);
returnhash_equals($expected, $headers['x-signature'] ?? '');
}
publicfunctionprocess(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
{
publicfunctiontype(): TriggerType { return TriggerType::Polling; }
publicfunctiononEnable(TriggerContext$ctx): void
{
// Store baseline for comparison$ctx->store()->put('last_rates', $this->service->getRates());
}
publicfunctiononDisable(TriggerContext$ctx): void
{
$ctx->store()->forget('last_rates');
}
publicfunctionpoll(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:

// Discoveryforeach ($registry->all() as$provider) {
if ($providerinstanceof 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) {
returnresponse()->json($handshake); // Challenge response
}
if ($trigger->verify($context, $headers, $rawBody)) {
$result = $trigger->process($context, json_decode($rawBody, true));
foreach ($result->eventsas$event) {
// Dispatch to automations, notify agents, etc.
}
}
// Disable$trigger->onDisable($context); // Deregisters webhook

Trigger Contracts

ContractTypePurpose
TriggerAbstract classBase for all triggers — lifecycle, processing, verification
TriggerContextInterfaceHost-provided: webhook URL, store, config
TriggerStoreInterfaceHost-provided: key-value persistence per subscription
TriggerResultValue objectWraps zero or more events from process/poll
TriggerTypeEnumWebhook or Polling
HasTriggersInterfaceOptional interface for trigger-capable providers

Making an Integration Configurable

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

useOpenCompany\IntegrationCore\Contracts\ConfigurableIntegration;
useOpenCompany\IntegrationCore\Contracts\ToolProvider;
class WeatherToolProvider implements ToolProvider, ConfigurableIntegration
{
// ... ToolProvider methods ...publicfunctionintegrationMeta(): 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
];
}
publicfunctionconfigSchema(): 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',
],
];
}
publicfunctiontestConnection(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()];
}
}
publicfunctionvalidationRules(): 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

Auth and Host Capabilities

Credential field shape is not enough to decide whether an integration can be configured in OpenCompany, KosmoKrator, or both. For example, an OAuth access token can be manually pasted in a CLI, while an OAuth redirect flow needs a web callback during setup but may still run in CLI after tokens are stored.

The catalog builder infers capability metadata for every integration:

  • auth.strategynone, api_key, api_token, bearer_token, oauth2_authorization_code, oauth2_manual_token, oauth2_client_credentials, basic, or custom
  • auth.setup_flowsnone, manual_secret, manual_token, web_redirect, local_redirect, device_code, service_account, client_credentials, or cli_only
  • host_availability.web — setup/runtime support in OpenCompany-style web hosts
  • host_availability.cli — setup/runtime support in KosmoKrator-style CLI hosts
  • runtime_requirements — local binaries or services required at runtime

If inference is not precise enough, implement OpenCompany\IntegrationCore\Contracts\HasIntegrationCapabilities on the provider and return explicit metadata:

publicfunctionintegrationCapabilities(): array
{
return [
'auth' => [
'strategy' => 'oauth2_authorization_code',
'setup_flows' => ['web_redirect'],
'requires_browser_for_setup' => true,
'refreshable' => true,
],
'host_availability' => [
'web' => ['setup_supported' => true, 'runtime_supported' => true, 'setup_mode' => 'web_redirect'],
'cli' => ['setup_supported' => false, 'runtime_supported' => true, 'setup_mode' => 'unsupported'],
],
];
}

Use local_redirect or device_code when an OAuth integration can be configured from a CLI host. Google OAuth is the main current example: web hosts use the registered redirect callback, while CLI hosts can use a desktop loopback redirect and, for supported scopes, device-code setup. Keep purely browser-callback OAuth integrations as web_redirect with CLI setup disabled; their tools may still run in CLI once the host already has stored tokens.

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```lualocalweather=app.integrations.weather.get({location="Amsterdam"})
localforecast=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
{
publicfunctionname(): string; // Slug for routing (e.g. 'get_weather')publicfunctiondescription(): string; // Shown in docs and catalogspublicfunctionparameters(): array; // Parameter definitionspublicfunctionexecute(array$args): ToolResult;
}

ToolProvider

Groups tools under an app, handles instantiation.

interface ToolProvider
{
publicfunctionappName(): string; // Unique identifierpublicfunctionappMeta(): array; // UI metadatapublicfunctiontools(): array; // Tool definitionspublicfunctionisIntegration(): bool; // Toggleable per agent?publicfunctioncreateTool(string$class, array$context = []): Tool;
publicfunctionluaDocsPath(): ?string; // Supplementary docspublicfunctioncredentialFields(): array; // Required credentials
}

CredentialResolver

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

interface CredentialResolver
{
publicfunctionget(string$integration, string$key, mixed$default = null, ?string$account = null): mixed;
publicfunctionisConfigured(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
{
publicfunctionintegrationMeta(): array; // Name, description, icon, categorypublicfunctionconfigSchema(): array; // Form field definitionspublicfunctiontestConnection(array$config): array; // Verify credentialspublicfunctionvalidationRules(): 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
{
publicfunctionsaveFile(
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
{
publicfunctioninvoke(string$toolSlug, array$args): mixed;
publicfunctiongetToolMeta(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
{
publicfunctiontriggers(): array; // Slug => {class, name, description, icon}publicfunctioncreateTrigger(string$class, array$context = []): Trigger;
}

Trigger

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

abstractclass Trigger
{
abstractpublicfunctionname(): string;
abstractpublicfunctiondescription(): string;
abstractpublicfunctiontype(): TriggerType; // Webhook or PollingabstractpublicfunctiononEnable(TriggerContext$ctx): void;
abstractpublicfunctiononDisable(TriggerContext$ctx): void;
publicfunctionparameters(): array; // Config fields (default: [])publicfunctionprocess(TriggerContext$ctx, array$payload): TriggerResult;
publicfunctionpoll(TriggerContext$ctx): TriggerResult;
publicfunctionverify(TriggerContext$ctx, array$headers, string$rawBody): bool;
publicfunctionhandshake(array$payload): ?array;
}

TriggerContext / TriggerStore

Host-provided interfaces for trigger infrastructure.

interface TriggerContext
{
publicfunctionwebhookUrl(): string; // Host-generated endpoint URLpublicfunctionstore(): TriggerStore; // Persistent key-value storagepublicfunctionconfig(): array; // User configuration values
}
interface TriggerStore
{
publicfunctionget(string$key, mixed$default = null): mixed;
publicfunctionput(string$key, mixed$value): void;
publicfunctionhas(string$key): bool;
publicfunctionforget(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.phpreturn [
'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:arrayconfig 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 optional $account parameter on CredentialResolver::get(), isConfigured(), and getAccounts() is the shared path for multi-account hosts. KosmoKrator uses it for headless named credentials; OpenCompany can map it to workspace-scoped account aliases.

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.neonparameters: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. Run php build-catalog.php and 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)
  • Capability metadata checked; add HasIntegrationCapabilities only when catalog inference is not specific enough
  • 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)
  • php build-catalog.php run, with generated auth/setup/SEO fields reviewed for CLI, Lua, and MCP gateway docs
  • 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

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages