Creating Plot Twists
    Preparing search index...

    Building Custom Tools

    Custom tools let you create reusable functionality that can be shared across twists or published for others to use. This guide covers everything you need to know about building tools.


    Build custom tools when you need to:

    • Integrate external services - GitHub, Slack, Notion, etc.
    • Encapsulate complex logic - Reusable business logic
    • Share functionality - Between multiple twists
    • Abstract implementation details - Clean interfaces for common operations
    Built-in Tools Custom Tools
    Plot, Store, AI, Network, etc. Your integrations and utilities
    Access to Plot internals Built on top of built-in tools
    Provided by twist Builder Created by you or installed from npm
    Always available Declared as dependencies

    Tools extend the Tool<T> base class and can access other tools through dependencies.

    import { Tool, type ToolBuilder } from "@plotday/twister";

    export class HelloTool extends Tool<HelloTool> {
    async sayHello(name: string): Promise<string> {
    return `Hello, ${name}!`;
    }
    }
    import { twist, type ToolBuilder } from "@plotday/twister";

    import { HelloTool } from "./tools/hello";

    export default class MyTwist extends Twist<MyTwist> {
    build(build: ToolBuilder) {
    return {
    hello: build(HelloTool),
    };
    }

    async activate() {
    const message = await this.tools.hello.sayHello("World");
    console.log(message); // "Hello, World!"
    }
    }

    import { Tool, type ToolBuilder } from "@plotday/twister";

    // Tool class with type parameter
    export class MyTool extends Tool<MyTool> {
    // Constructor receives id, options, and toolShed
    constructor(id: string, options: InferOptions<MyTool>, toolShed: ToolShed) {
    super(id, options, toolShed);
    }

    // Public methods
    async myMethod(): Promise<void> {
    // Implementation
    }
    }

    The type parameter <MyTool> enables:

    • Type-safe options inference
    • Type-safe tool dependencies
    • Proper TypeScript autocomplete

    Tools have lifecycle methods that run at specific times during the twist lifecycle.

    Called before the twist's activate() method, depth-first.

    async preActivate(priority: Priority): Promise<void> {
    // Setup that needs to happen before twist activation
    console.log("Tool preparing for activation");

    // Initialize connections, validate configuration, etc.
    }

    Use for:

    • Validating configuration
    • Setting up connections
    • Preparing resources

    Called after the twist's activate() method, reverse order.

    async postActivate(priority: Priority): Promise<void> {
    // Finalization after twist is activated
    console.log("Tool finalizing activation");

    // Start background processes, register webhooks, etc.
    }

    Use for:

    • Starting background processes
    • Registering webhooks
    • Final initialization

    Called before the twist's upgrade() method.

    async preUpgrade(): Promise<void> {
    // Prepare for upgrade
    const version = await this.get<string>("tool_version");

    if (version === "1.0.0") {
    // Migrate data
    }
    }

    Called after the twist's upgrade() method.

    async postUpgrade(): Promise<void> {
    // Finalize upgrade
    await this.set("tool_version", "2.0.0");
    }

    Called before the twist's deactivate() method.

    async preDeactivate(): Promise<void> {
    // Cleanup before deactivation
    await this.stopBackgroundProcesses();
    }

    Called after the twist's deactivate() method.

    async postDeactivate(): Promise<void> {
    // Final cleanup
    await this.clearAll();
    }
    twist Activation:
    1. Tool.preActivate() (deepest dependencies first)
    2. twist.activate()
    3. Tool.postActivate() (top-level tools first)

    twist Deactivation:
    1. Tool.preDeactivate() (deepest dependencies first)
    2. twist.deactivate()
    3. Tool.postDeactivate() (top-level tools first)

    Tools can depend on other tools, including built-in tools.

    import { Tool, type ToolBuilder } from "@plotday/twister";
    import { Network } from "@plotday/twister/tools/network";
    import { Store } from "@plotday/twister/tools/store";

    export class GitHubTool extends Tool<GitHubTool> {
    // Declare dependencies
    build(build: ToolBuilder) {
    return {
    network: build(Network, {
    urls: ["https://api.github.com/*"],
    }),
    store: build(Store),
    };
    }

    // Access dependencies
    async getRepository(owner: string, repo: string) {
    const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}`
    );
    return await response.json();
    }
    }

    Use this.tools to access declared dependencies:

    async fetchData() {
    // Tools are fully typed
    const data = await this.tools.network.fetch("https://api.example.com/data");
    await this.tools.store.set("cached_data", data);
    }

    Tools have direct access to Store, Tasks, and Callbacks methods:

    export class MyTool extends Tool<MyTool> {
    async doWork() {
    // Store
    await this.set("key", "value");
    const value = await this.get<string>("key");

    // Tasks
    const callback = await this.callback("processData");
    await this.runTask(callback);

    // Callbacks
    await this.deleteCallback(callback);
    }
    }

    Tools can accept configuration options when declared.

    import { Tool, type ToolBuilder, type InferOptions } from "@plotday/twister";

    export class SlackTool extends Tool<SlackTool> {
    // Define static Options type
    static Options = {
    workspaceId: "" as string,
    defaultChannel?: "" as string | undefined,
    };

    // Access via this.options
    async postMessage(message: string, channel?: string) {
    const targetChannel = channel || this.options.defaultChannel;

    if (!targetChannel) {
    throw new Error("No channel specified");
    }

    console.log(`Posting to ${targetChannel} in ${this.options.workspaceId}`);
    // Post message...
    }
    }
    build(build: ToolBuilder) {
    return {
    slack: build(SlackTool, {
    workspaceId: "T1234567",
    defaultChannel: "#general"
    }),
    };
    }
    static Options = {
    // Required - no default value, not undefined
    apiKey: "" as string,
    workspaceId: "" as string,

    // Optional - has undefined as possible value
    defaultChannel?: "" as string | undefined,
    timeout?: 0 as number | undefined,

    // Optional with default
    retryCount: 3 as number,
    };

    A complete GitHub integration with webhooks and issue management.

    import { type Priority, Tool, type ToolBuilder } from "@plotday/twister";
    import { ActivityLinkType, ActivityType } from "@plotday/twister";
    import { Network, type WebhookRequest } from "@plotday/twister/tools/network";
    import { Plot } from "@plotday/twister/tools/plot";

    export class GitHubTool extends Tool<GitHubTool> {
    static Options = {
    owner: "" as string,
    repo: "" as string,
    token: "" as string,
    };

    build(build: ToolBuilder) {
    return {
    network: build(Network, {
    urls: ["https://api.github.com/*"],
    }),
    plot: build(Plot),
    };
    }

    async postActivate(priority: Priority): Promise<void> {
    // Set up webhook for issue updates
    const webhookUrl = await this.tools.network.createWebhook("onIssueUpdate", {
    priorityId: priority.id,
    });

    await this.set("webhook_url", webhookUrl);

    // Register webhook with GitHub
    await this.registerWebhook(webhookUrl);
    }

    async preDeactivate(): Promise<void> {
    // Cleanup webhook
    const webhookUrl = await this.get<string>("webhook_url");
    if (webhookUrl) {
    await this.unregisterWebhook(webhookUrl);
    await this.tools.network.deleteWebhook(webhookUrl);
    }
    }

    async getIssues(): Promise<any[]> {
    const response = await fetch(
    `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/issues`,
    {
    headers: {
    Authorization: `Bearer ${this.options.token}`,
    Accept: "application/vnd.github.v3+json",
    },
    }
    );

    return await response.json();
    }

    async syncIssues(): Promise<void> {
    const issues = await this.getIssues();

    for (const issue of issues) {
    await this.tools.plot.createActivity({
    type: ActivityType.Task,
    title: issue.title,
    note: issue.body,
    meta: {
    github_issue_id: issue.id.toString(),
    github_number: issue.number.toString(),
    },
    links: [
    {
    type: ActivityLinkType.external,
    title: "View on GitHub",
    url: issue.html_url,
    },
    ],
    });
    }
    }

    async onIssueUpdate(
    request: WebhookRequest,
    context: { priorityId: string }
    ): Promise<void> {
    const { action, issue } = request.body;

    if (action === "opened") {
    // Create new activity for new issue
    await this.tools.plot.createActivity({
    type: ActivityType.Task,
    title: issue.title,
    meta: {
    github_issue_id: issue.id.toString(),
    },
    });
    } else if (action === "closed") {
    // Mark activity as done
    const activity = await this.tools.plot.getActivityByMeta({
    github_issue_id: issue.id.toString(),
    });

    if (activity) {
    await this.tools.plot.updateActivity(activity.id, {
    doneAt: new Date(),
    });
    }
    }
    }

    private async registerWebhook(url: string): Promise<void> {
    await fetch(
    `https://api.github.com/repos/${this.options.owner}/${this.options.repo}/hooks`,
    {
    method: "POST",
    headers: {
    Authorization: `Bearer ${this.options.token}`,
    Accept: "application/vnd.github.v3+json",
    },
    body: JSON.stringify({
    config: { url, content_type: "json" },
    events: ["issues"],
    }),
    }
    );
    }

    private async unregisterWebhook(url: string): Promise<void> {
    // Implementation to remove webhook from GitHub
    }
    }

    A tool for sending Slack notifications.

    import { Tool, type ToolBuilder } from "@plotday/twister";
    import { Network } from "@plotday/twister/tools/network";

    export class SlackTool extends Tool<SlackTool> {
    static Options = {
    webhookUrl: "" as string,
    defaultChannel?: "" as string | undefined,
    };

    build(build: ToolBuilder) {
    return {
    network: build(Network, {
    urls: ["https://hooks.slack.com/*"]
    }),
    };
    }

    async sendMessage(options: {
    text: string;
    channel?: string;
    username?: string;
    }): Promise<void> {
    const payload = {
    text: options.text,
    channel: options.channel || this.options.defaultChannel,
    username: options.username || "Plot Bot"
    };

    const response = await fetch(this.options.webhookUrl, {
    method: "POST",
    headers: {
    "Content-Type": "application/json"
    },
    body: JSON.stringify(payload)
    });

    if (!response.ok) {
    throw new Error(`Slack API error: ${response.statusText}`);
    }
    }

    async sendAlert(message: string): Promise<void> {
    await this.sendMessage({
    text: `:warning: ${message}`,
    channel: "#alerts"
    });
    }
    }

    import { beforeEach, describe, expect, it } from "vitest";

    import { GitHubTool } from "./github-tool";

    describe("GitHubTool", () => {
    let tool: GitHubTool;

    beforeEach(() => {
    tool = new GitHubTool(
    "test-id",
    {
    owner: "test-owner",
    repo: "test-repo",
    token: "test-token",
    },
    mockToolShed
    );
    });

    it("fetches issues", async () => {
    const issues = await tool.getIssues();
    expect(issues).toBeInstanceOf(Array);
    });

    it("validates configuration", () => {
    expect(tool.options.owner).toBe("test-owner");
    expect(tool.options.repo).toBe("test-repo");
    });
    });

    Test your tool with a real twist:

    import { twist, type ToolBuilder } from "@plotday/twister";
    import { Plot } from "@plotday/twister/tools/plot";

    import { GitHubTool } from "./github-tool";

    class TestTwist extends Twist<TestTwist> {
    build(build: ToolBuilder) {
    return {
    plot: build(Plot),
    github: build(GitHubTool, {
    owner: "plotday",
    repo: "plot",
    token: process.env.GITHUB_TOKEN!,
    }),
    };
    }

    async activate() {
    // Test syncing
    await this.tools.github.syncIssues();
    }
    }

    my-plot-tool/
    ├── src/
    │ └── index.ts # Tool implementation
    ├── package.json
    ├── tsconfig.json
    ├── README.md
    └── LICENSE
    {
    "name": "@mycompany/plot-github-tool",
    "version": "1.0.0",
    "description": "GitHub integration tool for Plot twists",
    "main": "./dist/index.js",
    "types": "./dist/index.d.ts",
    "scripts": {
    "build": "tsc",
    "test": "vitest"
    },
    "peerDependencies": {
    "@plotday/twister": "^0.16.0"
    },
    "devDependencies": {
    "@plotday/twister": "^0.16.0",
    "typescript": "^5.0.0"
    }
    }
    # Build
    npm run build

    # Test
    npm test

    # Publish
    npm publish

    Include comprehensive README with:

    • Installation instructions
    • Configuration options
    • Usage examples
    • API reference

    Each tool should have a single, well-defined purpose:

    // ✅ GOOD - Focused on GitHub
    class GitHubTool extends Tool<GitHubTool> {
    async getIssues() {
    /* ... */
    }
    async createIssue() {
    /* ... */
    }
    }

    // ❌ BAD - Mixed concerns
    class IntegrationTool extends Tool<IntegrationTool> {
    async getGitHubIssues() {
    /* ... */
    }
    async sendSlackMessage() {
    /* ... */
    }
    async createJiraTicket() {
    /* ... */
    }
    }

    Use TypeScript features for type safety:

    export interface GitHubIssue {
    id: number;
    title: string;
    body: string;
    state: "open" | "closed";
    }

    export class GitHubTool extends Tool<GitHubTool> {
    async getIssues(): Promise<GitHubIssue[]> {
    // Return type is enforced
    }
    }

    Handle errors gracefully:

    async fetchData(): Promise<Data | null> {
    try {
    const response = await fetch(this.apiUrl);

    if (!response.ok) {
    console.error(`API error: ${response.status}`);
    return null;
    }

    return await response.json();
    } catch (error) {
    console.error("Network error:", error);
    return null;
    }
    }

    Validate options in preActivate:

    async preActivate(priority: Priority): Promise<void> {
    if (!this.options.apiKey) {
    throw new Error("API key is required");
    }

    if (!this.options.workspaceId.startsWith("T")) {
    throw new Error("Invalid workspace ID format");
    }
    }

    Always clean up resources in deactivation:

    async postDeactivate(): Promise<void> {
    // Cancel pending tasks
    await this.cancelAllTasks();

    // Delete callbacks
    await this.deleteAllCallbacks();

    // Clear stored data
    await this.clearAll();
    }

    Use Store instead of instance variables:

    // ❌ WRONG - Instance state doesn't persist
    class MyTool extends Tool<MyTool> {
    private cache: Map<string, any> = new Map();
    }

    // ✅ CORRECT - Use Store
    class MyTool extends Tool<MyTool> {
    async getFromCache(key: string) {
    return await this.get<any>(`cache:${key}`);
    }

    async setInCache(key: string, value: any) {
    await this.set(`cache:${key}`, value);
    }
    }

    Add JSDoc comments for documentation:

    /**
    * Fetches all open issues from the GitHub repository.
    *
    * @returns Promise resolving to array of GitHub issues
    * @throws Error if GitHub API is unavailable
    *
    * @example
    * ```typescript
    * const issues = await this.tools.github.getIssues();
    * ```
    */
    async getIssues(): Promise<GitHubIssue[]> {
    // Implementation
    }