Extending in code
When YAML Custom tools aren't enough, extend TAI in TypeScript. This page covers the in-code surfaces and the two ways to distribute extensions.
Two routes
Plugin (preferred). Write the extension as a standalone package whose
default export receives a PluginContext. Register against ctx, return an
optional disposer, and import core types with import type. Users install the
package and add one line to plugins: in config.yaml. See
Plugins for the contract.
Fork. Clone the monorepo, add files to packages/core/, ship from
your fork. Use this when you're prototyping something that may merge
upstream, or when you need to touch the runtime itself rather than
register new pieces.
The APIs below are the same in both cases.
Adding a time provider
Time providers can supply the instant TAI treats as "now", a default IANA timezone, or both. Register one from the normal plugin context:
import type { Plugin } from "@tailored-ai/core";
export default ((ctx) => {
ctx.timeProviders.register("fixed", (config) => ({
now: () => new Date(String(config.time?.options?.now)),
timeZone: () => String(config.time?.options?.timezone ?? "UTC"),
}));
}) satisfies Plugin;
Select it in config:
time:
provider: fixed
# An explicit time.timezone overrides the provider's timeZone().
options:
now: 2030-01-02T03:04:05Z
timezone: Pacific/Honolulu
The runtime resolves the provider before tools at startup and hot reload. Schedule calculations, due-time polling, quiet-hour windows, and rendered time anchors consume it; absolute timestamps remain UTC in storage.
Adding a tool
A tool implements the Tool interface from @tailored-ai/core:
import type { Tool, ToolContext, ToolResult } from "@tailored-ai/core";
export class MyTool implements Tool {
name = "my_tool";
description = "Does something useful in one sentence.";
parameters = {
type: "object",
properties: {
input: { type: "string", description: "The input." },
},
required: ["input"],
};
constructor(private opts: { /* config from config.yaml */ }) {}
async execute(args: Record<string, unknown>, context: ToolContext): Promise<ToolResult> {
const input = args.input as string;
if (!input) {
return { success: false, output: "", error: "input is required." };
}
return { success: true, output: `Result: ${input}` };
}
async destroy(): Promise<void> {
// Optional cleanup when the tool is replaced on hot reload.
}
}
Conventions that keep tools local-model-friendly:
- Description ≤ 2 sentences. Small models pick tools by skimming descriptions. Long ones blur the choice.
- Required parameters explicit. TAI validates against your schema
before calling
execute. Don't??-default things that should be required. - Always return a
ToolResult. Throwing is fine for true bugs; expected failures (missing arg, network down) belong in{ success: false, error }. The agent loop reasons about errors. - Use
context.workingDirectoryif the tool touches the filesystem.
In a plugin, register the factory through the supplied context:
import type { Plugin } from "@tailored-ai/core";
export default ((ctx) => {
ctx.tools.register("my_tool", (config) => {
const cfg = config.tools.my_tool;
if (!cfg?.enabled) return [];
return [new MyTool(cfg)];
});
}) satisfies Plugin;
The factory runs every time createTools is called (startup + hot
reload). Return [] when the user hasn't enabled your tool.
A built-in tool uses the same factory registry. Add a config type in
packages/core/src/config.ts only when the tool belongs in core; plugin config
remains an opaque tools.<id> bag owned and validated by the plugin.
Adding a channel
Implement the Channel interface, then register a factory:
import {
findOrCreateSession,
runAgentLoop,
type Channel,
type AgentRuntime,
type Plugin,
} from "@tailored-ai/core";
class SlackChannel implements Channel {
id = "slack";
type = "slack";
constructor(private runtime: AgentRuntime, private cfg: { botToken: string }) {}
async connect() {
// Subscribe to events. For each incoming message:
const session = findOrCreateSession(
this.runtime.db,
`slack:${userId}`,
this.runtime.getModel(),
this.runtime.getConfig().agent.defaultProvider,
null,
);
const reply = await runAgentLoop(text, this.runtime.buildLoopOptions({ session }));
// Send reply back over your transport.
}
async disconnect() { /* close socket */ }
async send() { /* … */ }
}
export default ((ctx) => {
ctx.channels.register("slack", async (runtime, cfg) => {
const slack = new SlackChannel(runtime, { botToken: cfg.botToken as string });
await slack.connect();
return { channel: slack, disconnect: () => slack.disconnect() };
});
}) satisfies Plugin;
Enable in config.yaml:
channels:
slack:
enabled: true
botToken: ${SLACK_BOT_TOKEN}
The CLI starts every registered factory whose config has
enabled: true. packages/core/src/channels/discord.ts is the
reference implementation (~250 lines).
Adding a provider
This section is the compact API reference. If you are deciding whether a provider is necessary or building a distributable adapter, use the dedicated Build a model provider guide.
Implement AIProvider, then register a factory:
import {
type AIProvider,
type ChatParams,
type ChatResponse,
type Plugin,
} from "@tailored-ai/core";
class BedrockProvider implements AIProvider {
id = "bedrock";
name = "AWS Bedrock";
supportsTools = true;
async chat(params: ChatParams): Promise<ChatResponse> {
// Call Bedrock's invoke API. Return { content, role, toolCalls?, usage? }.
}
}
export default ((ctx) => {
ctx.providers.register("bedrock", (config) => {
const cfg = config.providers.bedrock as { region: string; model: string };
return { provider: new BedrockProvider(cfg), model: cfg.model };
});
}) satisfies Plugin;
Then agent.defaultProvider: bedrock picks it. openai.ts in
packages/core/src/providers/ and the provider plugin packages
(packages/provider-anthropic/, packages/provider-bedrock/) are the
references.
For OpenAI-compatible endpoints (vLLM, LM Studio, Together, OpenRouter),
no new provider needed. Use agent.defaultProvider: openai_compatible
and set providers.openai_compatible.baseUrl.
For custom embedding providers (Qdrant FastEmbed, Voyage, Cohere), the
same shape applies via ctx.embeddings.register(id, factory). Pick it with
memory.embeddings.type: <id>.
Adding a task backend
Implement TaskBackend, then register a factory:
import type { Plugin, TaskBackend } from "@tailored-ai/core";
class LinearTaskBackend implements TaskBackend {
async list(filter) { /* … */ }
async get(id) { /* … */ }
async create(input) { /* … */ }
async update(id, patch) { /* … */ }
async comment(id, body) { /* … */ }
async delete(id) { /* … */ }
}
export default ((ctx) => {
ctx.taskBackends.register("linear", (config, _db) => {
const cfg = config.tasks?.linear as { apiKey: string; teamId: string };
return new LinearTaskBackend(cfg);
});
}) satisfies Plugin;
Then tasks.backend: linear in config selects it. References:
packages/core/src/tasks/{native,github,beans,beads}.ts.
Adding a trigger
A trigger polls something and fires a workflow when its condition
matches. Implementation lives in packages/core/src/triggers/:
export interface Trigger {
start(): Promise<void>;
stop(): Promise<void>;
}
The trigger calls runWorkflow(name, input) when it fires. Simplest
reference: packages/core/src/triggers/file-drop.ts, ~50 lines.
Adding a workflow step type
Implement StepExecutor, then register it on the engine:
import type { StepExecutor, StepContext, StepResult } from "@tailored-ai/core";
const databaseQuery: StepExecutor = {
type: "database_query",
async execute(step, ctx: StepContext): Promise<StepResult> {
const { query, params } = step as { query: string; params?: unknown[] };
const rows = await myDb.query(query, params ?? []);
return { output: rows };
},
};
runtime.workflowEngine.registerExecutor(databaseQuery);
Any workflow YAML can then use type: database_query. Last
registration of a given type wins, so you can override built-ins.
Conventions
- TypeScript strict mode. The monorepo runs
strict: true. - ESM only. Internal imports use relative
.jsextensions (Node16 module resolution). - Cross-package imports use the
@tailored-ai/*workspace specifier. - Node 20 or newer.
engines.node: ">=20"on every published package. - Defaults in one place. New defaults go in
DEFAULT_CONFIGinpackages/core/src/config.ts, not scattered in code. - One way to do a thing. Before adding a second tool, channel, or backend that overlaps with an existing one, see if extending the existing one fits.
Distributing your extension
Publish as @your-org/tai-plugin-X on npm with the tai-plugin
keyword. Users install with npm install plus one entry in their
plugins: block:
plugins:
- "@your-org/tai-plugin-X"
For setup-specific extensions, the simplest distribution is your own
fork of the monorepo. The RELEASING.md at the repo root walks the
changesets-based publish flow if you want to ship from a fork.