Understanding these core concepts will help you build effective Plot Twists.
Twists are smart automations that connect, organize, and prioritize your work. They implement opinionated workflows and integrations.
A twist is a class that:
Twist<T> base classbuild() methodactivate, deactivate, upgrade)import { type Priority, type ToolBuilder, Twist } from "@plotday/twister";
import { Plot } from "@plotday/twister/tools/plot";
export default class MyTwist extends Twist<MyTwist> {
// 1. Declare dependencies
build(build: ToolBuilder) {
return {
plot: build(Plot),
};
}
// 2. Initialize on activation
async activate(priority: Pick<Priority, "id">) {
// Setup code - runs once when twist is added to a priority
}
// 3. Handle lifecycle events
async upgrade() {
// Runs when a new version is deployed
}
async deactivate() {
// Cleanup - runs when twist is removed
}
}
Use twists for:
Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. Tools encapsulate reusable capabilities and can be composed together.
Core Plot functionality provided by the Twist Creator:
See the Built-in Tools Guide for complete documentation.
Tools you create or install from npm packages:
See Building Custom Tools to create your own.
Use the build() method to declare which tools your twist needs:
build(build: ToolBuilder) {
return {
plot: build(Plot),
store: build(Store),
calendar: build(GoogleCalendar, {
// Tool-specific options
defaultCalendar: "primary"
}),
};
}
Access your tools via this.tools:
async activate(priority: Pick<Priority, "id">) {
// Tools are fully typed
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: "Hello from my twist"
});
}
Some tool methods are available directly on the Twist class for convenience:
// Store
await this.get("key");
await this.set("key", value);
await this.clear("key");
// Tasks
await this.runTask(callback);
await this.cancelTask(token);
// Callbacks
await this.callback("methodName", ...args);
await this.run(callbackToken);
Priorities are contexts that organize activities. Think of them like projects or focus areas.
Priorities can be nested to create hierarchies:
Work
├── Project A
│ ├── Backend
│ └── Frontend
└── Project B
// Top-level priority
const work = await this.tools.plot.createPriority({
title: "Work",
});
// Nested priority
const projectA = await this.tools.plot.createPriority({
title: "Project A",
parentId: work.id,
});
Twists are activated within a specific priority. When activated, the twist has access to that priority and all its children.
async activate(priority: Pick<Priority, "id">) {
// This twist is now active for this priority
// It can create activities, set up webhooks, etc.
}
Activities are the core data type in Plot, representing tasks, events, and notes.
Think of an Activity as a thread on a messaging platform, and Notes as the messages in that thread. An Activity represents something done or to be done, while Notes represent the updates and details on that activity. Always create activities with an initial note, and add notes for updates rather than creating new activities.
import { ActivityType } from "@plotday/twister";
// Note - Information without actionable requirements
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: "Meeting notes from sync",
notes: [
{
content: "Discussed Q1 roadmap and team priorities...",
},
],
});
// Task - Actionable item
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Review pull request",
done: null, // null = not done
notes: [
{
content: "PR adds new authentication flow. Please review for security concerns.",
},
],
});
// Event - Scheduled occurrence
await this.tools.plot.createActivity({
type: ActivityType.Event,
title: "Team standup",
start: new Date("2025-02-01T10:00:00Z"),
end: new Date("2025-02-01T10:30:00Z"),
notes: [
{
content: "Daily sync meeting",
},
],
});
When creating Activities of type Action (tasks), the start field determines how they appear in Plot:
Important: When creating an Action, omitting the start field defaults to the current time, making it a "Do Now" task.
For most integrations (project management tools, issue trackers), you should explicitly set start: null to create backlog items, only using "Do Now" for tasks that are actively in progress or urgent.
// "Do Now" - Appears in today's actionable list
// WARNING: This is the default when start is omitted!
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Urgent: Review security PR",
// Omitting start defaults to new Date()
});
// "Do Someday" - Backlog item (RECOMMENDED for most synced tasks)
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Refactor authentication service",
start: null, // Explicitly set to null for backlog
});
// "Do Later" - Scheduled for specific date
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Prepare Q1 review",
start: new Date("2025-03-15"), // Scheduled for future date
});
Use "Do Now" (omit start) when:
Use "Do Someday" (start: null) when:
Use "Do Later" (future start) when:
type Activity = {
id: string; // Unique identifier
type: ActivityType; // Note, Action, or Event
title: string | null; // Display title
preview: string | null; // Brief preview text
source: string | null; // Canonical URL for external item (enables automatic upserts)
start: Date | null; // Event start time
end: Date | null; // Event end time
done: Date | null; // Action completion time
tags: Record<Tag, ActorId[]>; // Tag assignments
// ... and more
};
Key Properties:
source: Canonical URL or stable identifier for items from external systems. When set, it uniquely identifies the activity within a priority tree and enables automatic deduplication. See Sync Strategies.type: Determines how the activity is displayed and interacted with (Note, Action with done, Event with start/end)title: Short summary that may be truncated in the UI - detailed content should go in NotesActivities can have multiple Notes attached to them, like messages in a thread. Notes contain detailed content and links.
Data Sync: When syncing from external systems, use Activity.source and Note.key for automatic upserts. See Sync Strategies.
await this.tools.plot.createActivity({
source: "https://github.com/org/repo/issues/123", // Enables automatic deduplication
type: ActivityType.Action,
title: "Fix bug #123",
notes: [
{
activity: { source: "https://github.com/org/repo/issues/123" },
key: "description", // Using key enables upserts
content: "Users are unable to log in with SSO. Error occurs in auth middleware.",
links: [
{
type: ActivityLinkType.external,
title: "View Issue",
url: "https://github.com/org/repo/issues/123",
},
{
type: ActivityLinkType.callback,
title: "Mark as Fixed",
callback: await this.callback("markAsFixed", "123"),
},
],
},
],
});
Link Types:
In most cases, an Activity should be created with at least one initial Note. The Activity's title is just a short summary that may be truncated in the UI. Detailed information, context, and links should always go in Notes.
Think of it like starting a new thread with a first message - the thread title gives context, but the real content is in the messages.
// ✅ GOOD - Activity with detailed Note (thread with first message)
await this.tools.plot.createActivity({
source: "https://github.com/org/repo/pull/456", // Enables automatic deduplication
type: ActivityType.Action,
title: "Review PR #456",
notes: [
{
activity: { source: "https://github.com/org/repo/pull/456" },
key: "description", // Using key enables upserts
content: "Please review the OAuth 2.0 implementation. Key changes include:\n- Token refresh logic\n- Session management\n- Error handling for expired tokens",
links: [
{
type: ActivityLinkType.external,
title: "View PR",
url: "https://github.com/org/repo/pull/456",
},
],
},
],
});
// ❌ BAD - Relying only on title
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Review PR #456 - OAuth implementation with token refresh and session management",
// Missing Notes with full context and links
});
Why? Just as you wouldn't create a messaging thread without a first message, Activities need Notes to provide meaningful context and detail.
Wherever possible, related messages should be added to an existing Activity rather than creating a new Activity. This keeps conversations, workflows, and related information together.
Think of it like replying to a message thread instead of starting a new thread for every reply.
Use this pattern for:
// ✅ GOOD - Add reply using source/key pattern (no lookup needed)
async onNewMessage(message: Message, threadId: string) {
// Simply create - Plot handles deduplication automatically
const threadSource = `chat:thread:${threadId}`;
await this.tools.plot.createNote({
activity: { source: threadSource }, // References activity by source
key: `message-${message.id}`, // Unique key per message for upserts
content: message.text,
});
// If thread doesn't exist yet, create it first
await this.tools.plot.createActivity({
source: threadSource, // Same source for deduplication
type: ActivityType.Note,
title: message.subject || "New conversation",
notes: [{
activity: { source: threadSource },
key: `message-${message.id}`,
content: message.text,
}],
});
}
// Alternative: Check existence first (for advanced cases)
async onNewMessageAdvanced(message: Message, threadId: string) {
const activity = await this.tools.plot.getActivityBySource({ threadId });
if (activity) {
await this.tools.plot.createNote({
activity: { id: activity.id },
content: message.text,
});
} else {
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: message.subject || "New conversation",
meta: { threadId },
notes: [{ content: message.text }],
});
}
}
// ❌ BAD - Creating separate Activity for each message (new thread for every reply!)
async onNewMessage(message: Message, threadId: string) {
// This creates clutter - each message becomes its own Activity
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: `Message from ${message.author}`,
notes: [{ content: message.text }],
});
}
See Sync Strategies for more details on choosing the right pattern.
Why? Grouping related content keeps the user's workspace organized and provides better context. A chat conversation with 20 messages should be one Activity with 20 Notes, not 20 separate Activities.
Twists have several lifecycle methods that are called at specific times.
Called when the twist is first activated for a priority.
Use for:
async activate(priority: Pick<Priority, "id">) {
// Create welcome message
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: "Calendar sync is now active"
});
// Set up webhook
const webhookUrl = await this.tools.network.createWebhook("onUpdate");
await this.set("webhook_url", webhookUrl);
}
Called when a new version of your twist is deployed to an existing priority.
Use for:
async upgrade() {
// Check version and migrate
const version = await this.get<string>("version");
if (!version || version < "2.0.0") {
// Migrate old data format
const oldData = await this.get("old_key");
await this.set("new_key", transformData(oldData));
await this.clear("old_key");
}
await this.set("version", "2.0.0");
}
Called when the twist is removed from a priority.
Use for:
async deactivate() {
// Clean up webhook
const webhookUrl = await this.get<string>("webhook_url");
if (webhookUrl) {
await this.tools.network.deleteWebhook(webhookUrl);
}
// Clean up stored data
await this.clearAll();
}
Use the Store tool for persistent state, not instance variables:
// ❌ WRONG - Instance variables don't persist
class MyTwist extends Twist<MyTwist> {
private syncToken: string; // This will be lost!
}
// ✅ CORRECT - Use Store
class MyTwist extends Twist<MyTwist> {
async getSyncToken() {
return await this.get<string>("sync_token");
}
async setSyncToken(token: string) {
await this.set("sync_token", token);
}
}
Always handle errors gracefully:
async activate(priority: Pick<Priority, "id">) {
try {
await this.tools.plot.createActivity({
type: ActivityType.Note,
title: "Twist activated"
});
} catch (error) {
console.error("Failed to create activity:", error);
// Twist activation continues even if this fails
}
}
Break long-running operations into batches:
async startSync() {
const callback = await this.callback("syncBatch", { page: 1 });
await this.runTask(callback);
}
async syncBatch(args: any, context: { page: number }) {
// Process one page
const hasMore = await processPage(context.page);
if (hasMore) {
// Queue next batch
const callback = await this.callback("syncBatch", {
page: context.page + 1
});
await this.runTask(callback);
}
}
See Runtime Environment for more details.
Leverage TypeScript for type safety:
// Define interfaces for stored data
interface SyncState {
lastSync: string;
token: string;
status: "active" | "paused";
}
async getSyncState(): Promise<SyncState | null> {
return await this.get<SyncState>("sync_state");
}
Build complex functionality by composing tools:
build(build: ToolBuilder) {
return {
plot: build(Plot),
network: build(Network, {
urls: ["https://api.service.com/*"]
}),
auth: build(Integrations),
ai: build(AI)
};
}
Make activity titles clear and actionable:
// ❌ Vague
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Thing",
});
// ✅ Clear
await this.tools.plot.createActivity({
type: ActivityType.Action,
title: "Review pull request #123 for authentication fix",
});