Plugins
A plugin is an npm package that extends TAI without forking the monorepo. Eight registries cover the extension surface today:
| Registry | Adds | Register with |
|---|---|---|
| Tool | A new tool the agent can call | registerToolFactory(id, factory) |
| Channel | A new transport (Slack, Telegram, …) | registerChannelFactory(id, factory) |
| Provider | A new LLM backend (Bedrock, Cohere, …) | registerProviderFactory(id, factory) |
| Embedding | A new embedding backend (Qdrant, Voyage, …) | registerEmbeddingFactory(id, factory) |
| Task backend | A new task store (Linear, Jira, …) | registerTaskBackendFactory(id, factory) |
| Step executor | A new workflow step type | runtime.getWorkflowEngine()?.registerExecutor(executor) |
| Trigger | A new workflow trigger kind | trigger-registry (see Workflows) |
| Skill | A SKILL.md the agent can load on demand | skill-registry (see Skills) |
Each registry is exported from @tailored-ai/core. Built-ins register
on module load.
Shape of a plugin
// my-tai-plugin/src/index.ts
import {
registerChannelFactory,
registerTaskBackendFactory,
registerToolFactory,
type Channel,
type TaskBackend,
type Tool,
type ToolFactoryContext,
} from "@tailored-ai/core";
class SlackChannel implements Channel { /* ... */ }
class LinearTaskBackend implements TaskBackend { /* ... */ }
class WeatherTool implements Tool { /* ... */ }
registerChannelFactory("slack", async (runtime, cfg) => {
const slack = new SlackChannel(runtime, cfg);
await slack.connect();
return { channel: slack, disconnect: () => slack.disconnect() };
});
registerTaskBackendFactory("linear", (config) => {
return new LinearTaskBackend({
apiKey: config.tasks?.linear?.apiKey as string,
teamId: config.tasks?.linear?.teamId as string,
});
});
registerToolFactory("weather", (config, _ctx: ToolFactoryContext) => {
const cfg = config.tools?.weather;
if (!cfg?.enabled) return [];
return [new WeatherTool({ apiKey: cfg.apiKey as string })];
});
A plugin doesn't need a wrapper class or lifecycle hook. Registering on
import is the contract. The user imports your package once at startup
and the registrations land.
Installing
tai plugin install @some-author/tai-plugin-slack
The plugin lands in <TAI_HOME>/plugins/ and the CLI appends it to
config.yaml's plugins: list for you (comments preserved; pass
--no-save to opt out). The list accepts bare names or objects:
plugins:
- "@some-author/tai-plugin-slack"
- module: "@me/tai-plugin-linear"
config:
api_token: ${LINEAR_TOKEN}
The CLI dynamic-imports each module at startup. The module's import
side-effects (registerToolFactory, registerChannelFactory, etc.)
register everything before the runtime asks. Failures are logged and the
next plugin is attempted — one broken plugin doesn't take down the
others.
The optional per-entry config field is reserved for future routing.
Today, plugins read their configuration from the normal tools.*,
channels.*, providers.* blocks in the same config file.
For embedders (anyone constructing AgentRuntime directly rather than
running tai), call loadPlugins(config, importer) before runtime
construction. The importer callback is required so dynamic import()
resolves against your package's node_modules, not core's:
import {
AgentRuntime,
createProvider,
loadConfig,
loadPlugins,
startRegisteredChannels,
} from "@tailored-ai/core";
const config = await loadConfig("./config.yaml");
await loadPlugins(config, (name) => import(name));
const db = new Database("./agent.db");
const { provider, model } = createProvider(config);
const runtime = new AgentRuntime({ config, db, provider, model });
const channels = await startRegisteredChannels(runtime);
Metadata and config validation
Two optional named exports sit next to the default register function.
meta describes the plugin for UIs and error hints — GET /api/plugins
returns it, and registers documents the link between the plugins: entry
(which loads code) and the config blocks (which turn features on):
import type { PluginMeta } from "@tailored-ai/core";
export const meta: PluginMeta = {
name: "AWS Bedrock provider",
description: "Bedrock-hosted models via the Converse API.",
registers: [{ kind: "provider", id: "bedrock", configKey: "providers.bedrock" }],
};
validateConfig checks the plugin's own config blocks at load time.
Core's validateConfig knows nothing about plugin config shapes on
purpose — the plugin owns them:
import type { AgentConfig } from "@tailored-ai/core";
export function validateConfig(config: AgentConfig): string[] {
const cfg = config.providers.bedrock as BedrockConfig | undefined;
if (cfg && !cfg.defaultModel) return ["providers.bedrock.defaultModel is empty"];
return [];
}
Warnings print at startup alongside core's config warnings and appear on
GET /api/plugins. A validator can't veto startup; factories still fail
fast for hard errors. Both exports are type-only contracts — like the
Plugin type itself, they add zero runtime dependency on core.
First-party plugins
These ship from the monorepo as separate npm packages:
| Package | What it adds | How to use today |
|---|---|---|
@tailored-ai/browser-mediator | The browser_mediator tool with egress allow-list, vault refs, always-HITL gates. | Already a dependency of @tailored-ai/core. Enable via tools.browser_mediator: { enabled: true }. |
@tailored-ai/trusted-actions | HITL approval gateway plus the request_action tool. | Install separately. Configure under tools.request_action. |
@tailored-ai/provider-bedrock | The bedrock model provider (AWS Bedrock Converse API). | Install, configure providers.bedrock, select with agent.defaultProvider: bedrock. |
@tailored-ai/provider-openrouter | The openrouter model provider (OpenAI-compatible). | Install, configure providers.openrouter, select with agent.defaultProvider: openrouter. |
@tailored-ai/provider-anthropic | The anthropic model provider (Messages API; supersedes the built-in). | Install, configure providers.anthropic, select with agent.defaultProvider: anthropic. |
@tailored-ai/provider-openai | The openai model provider (chat completions; supersedes the built-in). | Install, configure providers.openai, select with agent.defaultProvider: openai. |
@tailored-ai/channel-slack | The slack channel (Bolt, Socket Mode). | Install, configure channels.slack. |
@tailored-ai/google-tools | gmail, google_calendar, google_drive tools (via the gog CLI). | Install, enable under tools.gmail etc. |
Future first-party plugins under consideration: Telegram channel, Linear task backend, Notion knowledge base.
Patterns for plugin-friendly code
- Take all configuration via the factory's
configargument. No reading fromprocess.envdirectly. Let the user route env vars through YAML. - Fail fast in the factory. If required config is missing, throw with a useful message. The startup loop catches and logs.
- Dynamic-import heavy peer dependencies. If your plugin uses
playwright,await import("playwright")inside the connect handler, not at the top of the file.npm install your-pluginshouldn't drag Chromium along unless the user opts in. - Don't reach into TAI internals. Use the public exports of
@tailored-ai/core. The runtime shape may change between minor versions; public exports won't.
Why register instead of fork?
Forking the monorepo to add a channel or task backend is heavy. Wrapping shell commands in YAML custom tools is limited. The registries fill the gap: write idiomatic TypeScript, ship to npm, your users import your package once and the registrations land. The TAI monorepo doesn't grow. You don't carry a fork.
Where to read next
- Configuration → Plugins — config-block syntax reference.
- Extending in code — what each registry's interface looks like.