@plotday/twister 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/templates/AGENTS.template.md +8 -2
- package/cli/templates/AGENTS.template.md +8 -2
- package/dist/connector.d.ts +67 -7
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js +15 -5
- package/dist/connector.js.map +1 -1
- package/dist/docs/assets/hierarchy.js +1 -1
- package/dist/docs/assets/navigation.js +1 -1
- package/dist/docs/assets/search.js +1 -1
- package/dist/docs/classes/index.Connector.html +58 -49
- package/dist/docs/classes/index.Imap.html +1 -1
- package/dist/docs/classes/index.Options.html +1 -1
- package/dist/docs/classes/index.Smtp.html +1 -1
- package/dist/docs/classes/tools_ai.AI.html +1 -1
- package/dist/docs/classes/tools_callbacks.Callbacks.html +1 -1
- package/dist/docs/classes/tools_integrations.Integrations.html +21 -5
- package/dist/docs/classes/tools_network.Network.html +1 -1
- package/dist/docs/classes/tools_plot.Plot.html +1 -1
- package/dist/docs/classes/tools_store.Store.html +1 -1
- package/dist/docs/classes/tools_tasks.Tasks.html +1 -1
- package/dist/docs/classes/tools_twists.Twists.html +1 -1
- package/dist/docs/classes/twist.Twist.html +28 -28
- package/dist/docs/documents/Building_Connectors.html +8 -1
- package/dist/docs/enums/tag.Tag.html +11 -1
- package/dist/docs/enums/tools_integrations.AuthProvider.html +13 -13
- package/dist/docs/hierarchy.html +1 -1
- package/dist/docs/media/AGENTS.md +298 -775
- package/dist/docs/media/MULTI_USER_AUTH.md +6 -4
- package/dist/docs/media/SYNC_STRATEGIES.md +20 -14
- package/dist/docs/modules/index.html +1 -1
- package/dist/docs/types/index.CreateLinkDraft.html +7 -12
- package/dist/docs/types/index.NoteWriteBackResult.html +38 -0
- package/dist/docs/types/tools_integrations.ArchiveLinkFilter.html +5 -5
- package/dist/docs/types/tools_integrations.AuthToken.html +4 -4
- package/dist/docs/types/tools_integrations.Authorization.html +4 -4
- package/dist/llm-docs/connector.d.ts +1 -1
- package/dist/llm-docs/connector.d.ts.map +1 -1
- package/dist/llm-docs/connector.js +1 -1
- package/dist/llm-docs/connector.js.map +1 -1
- package/dist/llm-docs/tag.d.ts +1 -1
- package/dist/llm-docs/tag.d.ts.map +1 -1
- package/dist/llm-docs/tag.js +1 -1
- package/dist/llm-docs/tag.js.map +1 -1
- package/dist/llm-docs/tools/integrations.d.ts +1 -1
- package/dist/llm-docs/tools/integrations.d.ts.map +1 -1
- package/dist/llm-docs/tools/integrations.js +1 -1
- package/dist/llm-docs/tools/integrations.js.map +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts +1 -1
- package/dist/llm-docs/twist-guide-template.d.ts.map +1 -1
- package/dist/llm-docs/twist-guide-template.js +1 -1
- package/dist/llm-docs/twist-guide-template.js.map +1 -1
- package/dist/llm-docs/twist.d.ts +1 -1
- package/dist/llm-docs/twist.d.ts.map +1 -1
- package/dist/llm-docs/twist.js +1 -1
- package/dist/llm-docs/twist.js.map +1 -1
- package/dist/tag.d.ts +11 -1
- package/dist/tag.d.ts.map +1 -1
- package/dist/tag.js +10 -0
- package/dist/tag.js.map +1 -1
- package/dist/tools/integrations.d.ts +22 -0
- package/dist/tools/integrations.d.ts.map +1 -1
- package/dist/tools/integrations.js.map +1 -1
- package/dist/twist-guide.d.ts +1 -1
- package/dist/twist-guide.d.ts.map +1 -1
- package/dist/twist.d.ts +2 -1
- package/dist/twist.d.ts.map +1 -1
- package/dist/twist.js.map +1 -1
- package/dist/utils/markdown.d.ts +27 -0
- package/dist/utils/markdown.d.ts.map +1 -0
- package/dist/utils/markdown.js +82 -0
- package/dist/utils/markdown.js.map +1 -0
- package/package.json +6 -1
- package/src/connector.ts +68 -7
- package/src/llm-docs/connector.ts +1 -1
- package/src/llm-docs/tag.ts +1 -1
- package/src/llm-docs/tools/integrations.ts +1 -1
- package/src/llm-docs/twist-guide-template.ts +1 -1
- package/src/llm-docs/twist.ts +1 -1
- package/src/tag.ts +10 -0
- package/src/tools/integrations.ts +24 -0
- package/src/twist.ts +2 -1
- package/src/utils/markdown.ts +94 -0
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
# Connector Development Guide
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Covers everything needed to build a Plot connector correctly. For twists, see `../twister/cli/templates/AGENTS.template.md`. For navigation, `../AGENTS.md`. Type definitions with full JSDoc live in `../twister/src/tools/*.ts`.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
**For general navigation**: See `../AGENTS.md`
|
|
7
|
-
**For type definitions**: See `../twister/src/tools/*.ts` (comprehensive JSDoc)
|
|
8
|
-
|
|
9
|
-
## Quick Start: Complete Connector Scaffold
|
|
10
|
-
|
|
11
|
-
Every connector follows this structure:
|
|
5
|
+
## Scaffold
|
|
12
6
|
|
|
13
7
|
```
|
|
14
8
|
connectors/<name>/
|
|
15
9
|
src/
|
|
16
|
-
index.ts
|
|
17
|
-
<
|
|
18
|
-
<api-name>.ts
|
|
10
|
+
index.ts # export { default, ConnectorName } from "./connector-name";
|
|
11
|
+
<connector-name>.ts
|
|
12
|
+
<api-name>.ts # (optional)
|
|
19
13
|
package.json
|
|
20
14
|
tsconfig.json
|
|
21
15
|
README.md
|
|
22
16
|
LICENSE
|
|
23
17
|
```
|
|
24
18
|
|
|
25
|
-
### package.json
|
|
19
|
+
### package.json essentials
|
|
26
20
|
|
|
27
21
|
```json
|
|
28
22
|
{
|
|
@@ -44,30 +38,18 @@ connectors/<name>/
|
|
|
44
38
|
}
|
|
45
39
|
},
|
|
46
40
|
"files": ["dist", "README.md", "LICENSE"],
|
|
47
|
-
"scripts": {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
"dependencies": {
|
|
52
|
-
"@plotday/twister": "workspace:^"
|
|
53
|
-
},
|
|
54
|
-
"devDependencies": {
|
|
55
|
-
"typescript": "^5.9.3"
|
|
56
|
-
},
|
|
57
|
-
"repository": {
|
|
58
|
-
"type": "git",
|
|
59
|
-
"url": "https://github.com/plotday/plot.git",
|
|
60
|
-
"directory": "connectors/<name>"
|
|
61
|
-
},
|
|
41
|
+
"scripts": { "build": "tsc", "clean": "rm -rf dist" },
|
|
42
|
+
"dependencies": { "@plotday/twister": "workspace:^" },
|
|
43
|
+
"devDependencies": { "typescript": "^5.9.3" },
|
|
44
|
+
"repository": { "type": "git", "url": "https://github.com/plotday/plot.git", "directory": "connectors/<name>" },
|
|
62
45
|
"homepage": "https://plot.day",
|
|
63
|
-
"keywords": ["plot", "connector", "<name>"]
|
|
46
|
+
"keywords": ["plot", "connector", "<name>"]
|
|
64
47
|
}
|
|
65
48
|
```
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
- Add
|
|
70
|
-
- Add `@plotday/connector-google-contacts` as `"workspace:^"` if your connector syncs contacts (Google connectors only)
|
|
50
|
+
- `"@plotday/connector"` export condition resolves to TS source during workspace dev.
|
|
51
|
+
- Add third-party SDKs to `dependencies` (e.g. `"@linear/sdk": "^72.0.0"`).
|
|
52
|
+
- Add `@plotday/connector-google-contacts` as `"workspace:^"` if you sync contacts (Google connectors only).
|
|
71
53
|
|
|
72
54
|
### tsconfig.json
|
|
73
55
|
|
|
@@ -75,60 +57,35 @@ connectors/<name>/
|
|
|
75
57
|
{
|
|
76
58
|
"$schema": "https://json.schemastore.org/tsconfig",
|
|
77
59
|
"extends": "@plotday/twister/tsconfig.base.json",
|
|
78
|
-
"compilerOptions": {
|
|
79
|
-
"outDir": "./dist"
|
|
80
|
-
},
|
|
60
|
+
"compilerOptions": { "outDir": "./dist" },
|
|
81
61
|
"include": ["src/**/*.ts"]
|
|
82
62
|
}
|
|
83
63
|
```
|
|
84
64
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
```typescript
|
|
88
|
-
export { default, ConnectorName } from "./connector-name";
|
|
89
|
-
```
|
|
65
|
+
## Class skeleton
|
|
90
66
|
|
|
91
|
-
|
|
67
|
+
Use `connectors/linear/` as the canonical reference. Minimum shape:
|
|
92
68
|
|
|
93
69
|
```typescript
|
|
94
70
|
import {
|
|
95
|
-
ActivityType,
|
|
96
|
-
|
|
97
|
-
type NewActivity,
|
|
98
|
-
type NewActivityWithNotes,
|
|
99
|
-
type NewNote,
|
|
100
|
-
type SyncToolOptions,
|
|
101
|
-
Connector,
|
|
102
|
-
type ConnectorBuilder,
|
|
71
|
+
ActivityType, LinkType, Connector,
|
|
72
|
+
type NewActivityWithNotes, type SyncToolOptions, type ConnectorBuilder,
|
|
103
73
|
} from "@plotday/twister";
|
|
104
|
-
import type { NewContact } from "@plotday/twister/plot";
|
|
105
74
|
import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks";
|
|
106
75
|
import {
|
|
107
|
-
AuthProvider,
|
|
108
|
-
type AuthToken,
|
|
109
|
-
type Authorization,
|
|
110
|
-
Integrations,
|
|
111
|
-
type Channel,
|
|
76
|
+
AuthProvider, Integrations,
|
|
77
|
+
type AuthToken, type Authorization, type Channel,
|
|
112
78
|
} from "@plotday/twister/tools/integrations";
|
|
113
79
|
import { Network, type WebhookRequest } from "@plotday/twister/tools/network";
|
|
114
80
|
import { Tasks } from "@plotday/twister/tools/tasks";
|
|
115
81
|
|
|
116
|
-
type SyncState = {
|
|
117
|
-
cursor: string | null;
|
|
118
|
-
batchNumber: number;
|
|
119
|
-
itemsProcessed: number;
|
|
120
|
-
initialSync: boolean;
|
|
121
|
-
};
|
|
122
|
-
|
|
123
82
|
export class MyConnector extends Connector<MyConnector> {
|
|
124
|
-
|
|
125
|
-
static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider
|
|
83
|
+
static readonly PROVIDER = AuthProvider.Linear;
|
|
126
84
|
static readonly SCOPES = ["read", "write"];
|
|
127
85
|
static readonly Options: SyncToolOptions;
|
|
128
|
-
static readonly handleReplies = true; //
|
|
86
|
+
static readonly handleReplies = true; // only for bidirectional connectors
|
|
129
87
|
declare readonly Options: SyncToolOptions;
|
|
130
88
|
|
|
131
|
-
// 2. Declare dependencies
|
|
132
89
|
build(build: ConnectorBuilder) {
|
|
133
90
|
return {
|
|
134
91
|
integrations: build(Integrations, {
|
|
@@ -146,860 +103,426 @@ export class MyConnector extends Connector<MyConnector> {
|
|
|
146
103
|
};
|
|
147
104
|
}
|
|
148
105
|
|
|
149
|
-
|
|
150
|
-
private async getClient(channelId: string): Promise<any> {
|
|
151
|
-
const token = await this.tools.integrations.get(MyConnector.PROVIDER, channelId);
|
|
152
|
-
if (!token) throw new Error("No authentication token available");
|
|
153
|
-
return new SomeApiClient({ accessToken: token.token });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// 4. Return available resources for the user to select
|
|
157
|
-
async getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {
|
|
158
|
-
const client = new SomeApiClient({ accessToken: token.token });
|
|
159
|
-
const resources = await client.listResources();
|
|
160
|
-
return resources.map(r => ({ id: r.id, title: r.name }));
|
|
161
|
-
}
|
|
106
|
+
async getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> { /* list resources */ }
|
|
162
107
|
|
|
163
|
-
// 5. Called when user enables a resource
|
|
164
108
|
async onChannelEnabled(channel: Channel): Promise<void> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
const itemCallbackToken = await this.tools.callbacks.createFromParent(
|
|
169
|
-
this.options.onItem
|
|
170
|
-
);
|
|
171
|
-
await this.set(`item_callback_${channel.id}`, itemCallbackToken);
|
|
172
|
-
|
|
173
|
-
if (this.options.onChannelDisabled) {
|
|
174
|
-
const disableCallbackToken = await this.tools.callbacks.createFromParent(
|
|
175
|
-
this.options.onChannelDisabled,
|
|
176
|
-
{ meta: { syncProvider: "myprovider", channelId: channel.id } }
|
|
177
|
-
);
|
|
178
|
-
await this.set(`disable_callback_${channel.id}`, disableCallbackToken);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Queue webhook and sync as separate tasks — do NOT run inline,
|
|
182
|
-
// onChannelEnabled blocks the HTTP response until it returns
|
|
183
|
-
const webhookCallback = await this.callback(this.setupWebhook, channel.id);
|
|
184
|
-
await this.runTask(webhookCallback);
|
|
185
|
-
await this.startBatchSync(channel.id);
|
|
109
|
+
// 1. Store parent callback tokens via tools.callbacks.createFromParent(this.options.onItem)
|
|
110
|
+
// 2. Queue webhook + initial sync as SEPARATE tasks via runTask() — never inline;
|
|
111
|
+
// onChannelEnabled blocks the HTTP response until it returns.
|
|
186
112
|
}
|
|
187
113
|
|
|
188
|
-
// 6. Called when user disables a resource
|
|
189
114
|
async onChannelDisabled(channel: Channel): Promise<void> {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const disableCallbackToken = await this.get<Callback>(`disable_callback_${channel.id}`);
|
|
193
|
-
if (disableCallbackToken) {
|
|
194
|
-
await this.tools.callbacks.run(disableCallbackToken);
|
|
195
|
-
await this.tools.callbacks.delete(disableCallbackToken);
|
|
196
|
-
await this.clear(`disable_callback_${channel.id}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const itemCallbackToken = await this.get<Callback>(`item_callback_${channel.id}`);
|
|
200
|
-
if (itemCallbackToken) {
|
|
201
|
-
await this.tools.callbacks.delete(itemCallbackToken);
|
|
202
|
-
await this.clear(`item_callback_${channel.id}`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
await this.clear(`sync_enabled_${channel.id}`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// 7. Public interface methods (from common interface)
|
|
209
|
-
async getProjects(projectId: string): Promise<any[]> {
|
|
210
|
-
const client = await this.getClient(projectId);
|
|
211
|
-
const projects = await client.listProjects();
|
|
212
|
-
return projects.map(p => ({
|
|
213
|
-
id: p.id,
|
|
214
|
-
name: p.name,
|
|
215
|
-
description: p.description || null,
|
|
216
|
-
key: p.key || null,
|
|
217
|
-
}));
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
async startSync<TArgs extends any[], TCallback extends Function>(
|
|
221
|
-
options: { projectId: string },
|
|
222
|
-
callback: TCallback,
|
|
223
|
-
...extraArgs: TArgs
|
|
224
|
-
): Promise<void> {
|
|
225
|
-
const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
|
|
226
|
-
await this.set(`item_callback_${options.projectId}`, callbackToken);
|
|
227
|
-
const webhookCallback = await this.callback(this.setupWebhook, options.projectId);
|
|
228
|
-
await this.runTask(webhookCallback);
|
|
229
|
-
await this.startBatchSync(options.projectId);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
async stopSync(projectId: string): Promise<void> {
|
|
233
|
-
// Remove webhook
|
|
234
|
-
const webhookId = await this.get<string>(`webhook_id_${projectId}`);
|
|
235
|
-
if (webhookId) {
|
|
236
|
-
try {
|
|
237
|
-
const client = await this.getClient(projectId);
|
|
238
|
-
await client.deleteWebhook(webhookId);
|
|
239
|
-
} catch (error) {
|
|
240
|
-
console.warn("Failed to delete webhook:", error);
|
|
241
|
-
}
|
|
242
|
-
await this.clear(`webhook_id_${projectId}`);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Cleanup callbacks
|
|
246
|
-
const itemCallbackToken = await this.get<Callback>(`item_callback_${projectId}`);
|
|
247
|
-
if (itemCallbackToken) {
|
|
248
|
-
await this.deleteCallback(itemCallbackToken);
|
|
249
|
-
await this.clear(`item_callback_${projectId}`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
await this.clear(`sync_state_${projectId}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// 8. Webhook setup (non-private so it can be used with this.callback())
|
|
256
|
-
async setupWebhook(resourceId: string): Promise<void> {
|
|
257
|
-
try {
|
|
258
|
-
const webhookUrl = await this.tools.network.createWebhook(
|
|
259
|
-
{},
|
|
260
|
-
this.onWebhook,
|
|
261
|
-
resourceId
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
// REQUIRED: Skip webhook registration in development
|
|
265
|
-
if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const client = await this.getClient(resourceId);
|
|
270
|
-
const webhook = await client.createWebhook({ url: webhookUrl });
|
|
271
|
-
if (webhook?.id) {
|
|
272
|
-
await this.set(`webhook_id_${resourceId}`, webhook.id);
|
|
273
|
-
}
|
|
274
|
-
} catch (error) {
|
|
275
|
-
console.error("Failed to set up webhook:", error);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 9. Batch sync
|
|
280
|
-
private async startBatchSync(resourceId: string): Promise<void> {
|
|
281
|
-
await this.set(`sync_state_${resourceId}`, {
|
|
282
|
-
cursor: null,
|
|
283
|
-
batchNumber: 1,
|
|
284
|
-
itemsProcessed: 0,
|
|
285
|
-
initialSync: true,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
const batchCallback = await this.callback(this.syncBatch, resourceId);
|
|
289
|
-
await this.tools.tasks.runTask(batchCallback);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
private async syncBatch(resourceId: string): Promise<void> {
|
|
293
|
-
const state = await this.get<SyncState>(`sync_state_${resourceId}`);
|
|
294
|
-
if (!state) throw new Error(`Sync state not found for ${resourceId}`);
|
|
295
|
-
|
|
296
|
-
const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
|
|
297
|
-
if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
|
|
298
|
-
|
|
299
|
-
const client = await this.getClient(resourceId);
|
|
300
|
-
const result = await client.listItems({ cursor: state.cursor, limit: 50 });
|
|
301
|
-
|
|
302
|
-
for (const item of result.items) {
|
|
303
|
-
const activity = this.transformItem(item, resourceId, state.initialSync);
|
|
304
|
-
// Inject sync metadata for bulk operations
|
|
305
|
-
activity.meta = {
|
|
306
|
-
...activity.meta,
|
|
307
|
-
syncProvider: "myprovider",
|
|
308
|
-
channelId: resourceId,
|
|
309
|
-
};
|
|
310
|
-
await this.tools.callbacks.run(callbackToken, activity);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if (result.nextCursor) {
|
|
314
|
-
await this.set(`sync_state_${resourceId}`, {
|
|
315
|
-
cursor: result.nextCursor,
|
|
316
|
-
batchNumber: state.batchNumber + 1,
|
|
317
|
-
itemsProcessed: state.itemsProcessed + result.items.length,
|
|
318
|
-
initialSync: state.initialSync,
|
|
319
|
-
});
|
|
320
|
-
const nextBatch = await this.callback(this.syncBatch, resourceId);
|
|
321
|
-
await this.tools.tasks.runTask(nextBatch);
|
|
322
|
-
} else {
|
|
323
|
-
await this.clear(`sync_state_${resourceId}`);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// 10. Data transformation
|
|
328
|
-
private transformItem(item: any, resourceId: string, initialSync: boolean): NewActivityWithNotes {
|
|
329
|
-
return {
|
|
330
|
-
source: `myprovider:item:${item.id}`, // Canonical source for upsert
|
|
331
|
-
type: ActivityType.Action,
|
|
332
|
-
title: item.title,
|
|
333
|
-
created: item.createdAt,
|
|
334
|
-
author: item.creator?.email ? {
|
|
335
|
-
email: item.creator.email,
|
|
336
|
-
name: item.creator.name,
|
|
337
|
-
} : undefined,
|
|
338
|
-
meta: {
|
|
339
|
-
externalId: item.id,
|
|
340
|
-
resourceId,
|
|
341
|
-
},
|
|
342
|
-
notes: [{
|
|
343
|
-
key: "description", // Enables note upsert
|
|
344
|
-
content: item.description || null,
|
|
345
|
-
contentType: item.descriptionHtml ? "html" as const : "text" as const,
|
|
346
|
-
links: item.url ? [{
|
|
347
|
-
type: LinkType.external,
|
|
348
|
-
title: "Open in Service",
|
|
349
|
-
url: item.url,
|
|
350
|
-
}] : null,
|
|
351
|
-
}],
|
|
352
|
-
...(initialSync ? { unread: false } : {}),
|
|
353
|
-
...(initialSync ? { archived: false } : {}),
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// 11. Webhook handler
|
|
358
|
-
private async onWebhook(request: WebhookRequest, resourceId: string): Promise<void> {
|
|
359
|
-
// Verify webhook signature (provider-specific)
|
|
360
|
-
// ...
|
|
361
|
-
|
|
362
|
-
const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
|
|
363
|
-
if (!callbackToken) return;
|
|
364
|
-
|
|
365
|
-
const payload = JSON.parse(request.rawBody || "{}");
|
|
366
|
-
const activity = this.transformItem(payload.item, resourceId, false);
|
|
367
|
-
activity.meta = {
|
|
368
|
-
...activity.meta,
|
|
369
|
-
syncProvider: "myprovider",
|
|
370
|
-
channelId: resourceId,
|
|
371
|
-
};
|
|
372
|
-
await this.tools.callbacks.run(callbackToken, activity);
|
|
115
|
+
// Delete webhook, delete callback tokens, clear() all per-channel state.
|
|
373
116
|
}
|
|
374
117
|
}
|
|
375
118
|
|
|
376
119
|
export default MyConnector;
|
|
377
120
|
```
|
|
378
121
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
**This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Connectors declare their provider config in `build()`.
|
|
382
|
-
|
|
383
|
-
### How It Works
|
|
384
|
-
|
|
385
|
-
1. Connector declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks
|
|
386
|
-
2. User clicks "Connect" in the twist edit modal → OAuth flow happens automatically
|
|
387
|
-
3. After auth, the runtime calls your `getChannels()` to list available resources
|
|
388
|
-
4. User enables resources in the modal → `onChannelEnabled()` fires
|
|
389
|
-
5. User disables resources → `onChannelDisabled()` fires
|
|
390
|
-
6. Get tokens via `this.tools.integrations.get(PROVIDER, channelId)`
|
|
391
|
-
|
|
392
|
-
### Available Providers
|
|
122
|
+
Required private helpers: `getClient(channelId)`, `setupWebhook(id)`, `startBatchSync(id)`, `syncBatch(id)`, `transformItem(item, id, initialSync)`, `onWebhook(req, id)`. See `linear/` for the full pattern.
|
|
393
123
|
|
|
394
|
-
|
|
124
|
+
## Integrations (auth + channels)
|
|
395
125
|
|
|
396
|
-
|
|
126
|
+
Auth is handled in the Flutter edit modal — you declare providers in `build()`, the runtime drives OAuth.
|
|
397
127
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
MyConnector.PROVIDER,
|
|
403
|
-
actorId, // The user who performed the action
|
|
404
|
-
activityId, // Activity to create auth prompt in (if user hasn't connected)
|
|
405
|
-
this.performWriteBack,
|
|
406
|
-
...extraArgs
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
async performWriteBack(token: AuthToken, ...extraArgs: any[]): Promise<void> {
|
|
410
|
-
// token is the acting user's token
|
|
411
|
-
const client = new ApiClient({ accessToken: token.token });
|
|
412
|
-
await client.doSomething();
|
|
413
|
-
}
|
|
414
|
-
```
|
|
128
|
+
1. User clicks "Connect" → OAuth runs automatically.
|
|
129
|
+
2. Runtime calls your `getChannels()` to list resources.
|
|
130
|
+
3. User enables → `onChannelEnabled()`. User disables → `onChannelDisabled()`.
|
|
131
|
+
4. Read tokens via `this.tools.integrations.get(PROVIDER, channelId)`.
|
|
415
132
|
|
|
416
|
-
|
|
133
|
+
`AuthProvider` values: `Google`, `Microsoft`, `Notion`, `Slack`, `Atlassian`, `Linear`, `Monday`, `GitHub`, `Asana`, `HubSpot`.
|
|
417
134
|
|
|
418
|
-
|
|
135
|
+
### Per-user auth for write-backs
|
|
419
136
|
|
|
420
137
|
```typescript
|
|
421
|
-
|
|
138
|
+
await this.tools.integrations.actAs(MyConnector.PROVIDER, actorId, activityId, this.performWriteBack, ...args);
|
|
422
139
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
integrations: build(Integrations, {
|
|
426
|
-
providers: [{
|
|
427
|
-
provider: AuthProvider.Google,
|
|
428
|
-
scopes: Integrations.MergeScopes(
|
|
429
|
-
MyGoogleConnector.SCOPES,
|
|
430
|
-
GoogleContacts.SCOPES
|
|
431
|
-
),
|
|
432
|
-
getChannels: this.getChannels,
|
|
433
|
-
onChannelEnabled: this.onChannelEnabled,
|
|
434
|
-
onChannelDisabled: this.onChannelDisabled,
|
|
435
|
-
}],
|
|
436
|
-
}),
|
|
437
|
-
googleContacts: build(GoogleContacts),
|
|
438
|
-
// ...
|
|
439
|
-
};
|
|
140
|
+
async performWriteBack(token: AuthToken, ...args: any[]): Promise<void> {
|
|
141
|
+
// token is the acting user's token
|
|
440
142
|
}
|
|
441
143
|
```
|
|
442
144
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
**Connectors save data directly** via `integrations.saveLink()`. Connectors build `NewLinkWithNotes` objects and save them, rather than passing them through a parent twist.
|
|
446
|
-
|
|
447
|
-
This means:
|
|
448
|
-
- Connectors request `Plot` with `ContactAccess.Write` (for contacts on threads)
|
|
449
|
-
- Connectors declare providers via `Integrations` with lifecycle callbacks
|
|
450
|
-
- Connectors call save methods directly to persist synced data
|
|
145
|
+
### Cross-connector auth sharing (Google)
|
|
451
146
|
|
|
452
|
-
|
|
147
|
+
Merge scopes with `Integrations.MergeScopes(MyGoogleConnector.SCOPES, GoogleContacts.SCOPES)` and add `googleContacts: build(GoogleContacts)` to your `build()` return.
|
|
453
148
|
|
|
454
|
-
|
|
149
|
+
## Architecture
|
|
455
150
|
|
|
456
|
-
|
|
151
|
+
Connectors persist data directly via `integrations.saveLink()` (building `NewLinkWithNotes`). They do not push through a parent twist, and should not call `plot.createThread()`.
|
|
457
152
|
|
|
458
|
-
|
|
459
|
-
async startSync(callback: Function, ...extraArgs: any[]): Promise<void> {
|
|
460
|
-
// ❌ WRONG: callback is a function — NOT SERIALIZABLE!
|
|
461
|
-
await this.callback(this.syncBatch, callback, ...extraArgs);
|
|
462
|
-
}
|
|
463
|
-
```
|
|
153
|
+
## Callback serialization (the #1 mistake)
|
|
464
154
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
### ✅ CORRECT - Store Token, Pass Primitives
|
|
155
|
+
Functions are not serializable across worker boundaries. Convert to tokens, store primitives.
|
|
468
156
|
|
|
469
157
|
```typescript
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
await this.set(`callback_${resourceId}`, callbackToken);
|
|
474
|
-
|
|
475
|
-
// Step 2: Pass ONLY serializable values to this.callback()
|
|
476
|
-
const batchCallback = await this.callback(this.syncBatch, resourceId);
|
|
477
|
-
await this.tools.tasks.runTask(batchCallback);
|
|
478
|
-
}
|
|
158
|
+
// ❌ WRONG — passing a function as a callback arg
|
|
159
|
+
await this.callback(this.syncBatch, callback, ...extraArgs);
|
|
160
|
+
// Error: Cannot create callback args: Found function at path "value[0]"
|
|
479
161
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
162
|
+
// ✅ CORRECT
|
|
163
|
+
const token = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
|
|
164
|
+
await this.set(`callback_${resourceId}`, token);
|
|
165
|
+
const batch = await this.callback(this.syncBatch, resourceId); // primitives only
|
|
166
|
+
await this.tools.tasks.runTask(batch);
|
|
484
167
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
}
|
|
168
|
+
// In syncBatch:
|
|
169
|
+
const token = await this.get<Callback>(`callback_${resourceId}`);
|
|
170
|
+
if (!token) throw new Error(`Callback not found for ${resourceId}`);
|
|
171
|
+
await this.tools.callbacks.run(token, item);
|
|
491
172
|
```
|
|
492
173
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
| ✅ Safe | ❌ NOT Serializable |
|
|
496
|
-
|---------|---------------------|
|
|
497
|
-
| Strings, numbers, booleans, null | Functions, `() => {}`, method refs |
|
|
498
|
-
| Plain objects `{ key: "value" }` | `undefined` (use `null` instead) |
|
|
499
|
-
| Arrays `[1, 2, 3]` | Symbols |
|
|
500
|
-
| Dates (serialized via SuperJSON) | RPC stubs |
|
|
501
|
-
| Callback tokens (branded strings) | Circular references |
|
|
174
|
+
Serializable: strings, numbers, booleans, `null`, plain objects, arrays, Dates (SuperJSON), callback tokens.
|
|
175
|
+
Not serializable: functions, `undefined` (use `null`), symbols, RPC stubs, circular refs.
|
|
502
176
|
|
|
503
|
-
## Callback
|
|
177
|
+
## Callback backward compatibility
|
|
504
178
|
|
|
505
|
-
|
|
179
|
+
Deployed callbacks auto-upgrade to new connector versions. Signatures must stay compatible:
|
|
506
180
|
|
|
507
|
-
-
|
|
508
|
-
-
|
|
509
|
-
- ✅ Do handle both old and new data formats with version guards
|
|
181
|
+
- ✅ Add optional params at the end with safe defaults.
|
|
182
|
+
- ❌ Don't remove/reorder params or change existing types.
|
|
510
183
|
|
|
511
184
|
```typescript
|
|
512
|
-
// v1.0
|
|
185
|
+
// v1.0
|
|
513
186
|
async syncBatch(batchNumber: number, resourceId: string) { ... }
|
|
514
|
-
|
|
515
|
-
// v1.1 - ✅ GOOD: Optional parameter at end
|
|
187
|
+
// v1.1 — safe
|
|
516
188
|
async syncBatch(batchNumber: number, resourceId: string, initialSync?: boolean) {
|
|
517
|
-
const isInitial = initialSync ?? true;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// v2.0 - ❌ BAD: Completely changed signature
|
|
521
|
-
async syncBatch(options: SyncOptions) { ... }
|
|
522
|
-
```
|
|
523
|
-
|
|
524
|
-
For breaking changes, implement migration logic in `preUpgrade()`:
|
|
525
|
-
|
|
526
|
-
```typescript
|
|
527
|
-
async preUpgrade(): Promise<void> {
|
|
528
|
-
// Clean up stale locks from previous version
|
|
529
|
-
const keys = await this.list("sync_lock_");
|
|
530
|
-
for (const key of keys) {
|
|
531
|
-
await this.clear(key);
|
|
532
|
-
}
|
|
189
|
+
const isInitial = initialSync ?? true;
|
|
533
190
|
}
|
|
534
191
|
```
|
|
535
192
|
|
|
536
|
-
|
|
193
|
+
For breaking changes, do migration in `preUpgrade()` (e.g. clear stale locks).
|
|
537
194
|
|
|
538
|
-
|
|
195
|
+
## Storage key conventions
|
|
539
196
|
|
|
540
|
-
| Key
|
|
541
|
-
|
|
542
|
-
| `item_callback_<id>` |
|
|
543
|
-
| `disable_callback_<id>` |
|
|
197
|
+
| Key | Purpose |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `item_callback_<id>` | Token for parent's `onItem` |
|
|
200
|
+
| `disable_callback_<id>` | Token for parent's `onChannelDisabled` |
|
|
544
201
|
| `sync_state_<id>` | Current batch pagination state |
|
|
545
202
|
| `sync_enabled_<id>` | Boolean tracking enabled state |
|
|
546
|
-
| `webhook_id_<id>` | External webhook registration
|
|
203
|
+
| `webhook_id_<id>` | External webhook registration id |
|
|
547
204
|
| `webhook_secret_<id>` | Webhook signing secret |
|
|
548
|
-
| `watch_renewal_task_<id>` | Scheduled task
|
|
549
|
-
|
|
550
|
-
## Activity Source URL Conventions
|
|
551
|
-
|
|
552
|
-
The `activity.source` field is the idempotency key for automatic upserts. Use a canonical format:
|
|
553
|
-
|
|
554
|
-
```
|
|
555
|
-
<provider>:<entity>:<id> — Standard pattern
|
|
556
|
-
<provider>:<namespace>:<id> — When provider has multiple entity types
|
|
557
|
-
```
|
|
205
|
+
| `watch_renewal_task_<id>` | Scheduled task for webhook renewal |
|
|
558
206
|
|
|
559
|
-
|
|
207
|
+
## `source` — idempotency + cross-user dedup (CRITICAL)
|
|
560
208
|
|
|
561
|
-
`source` is the
|
|
209
|
+
`activity.source` / `link.source` is the upsert key AND the cross-user dedup key: two instances emitting the same `source` converge on a single shared thread across users (that's how two users on the same Gmail message share one thread).
|
|
562
210
|
|
|
563
|
-
|
|
211
|
+
**Your `source` must be globally unique for the external item, not merely unique within a user's account.** If two different users' connectors could emit the same string for different items, include a qualifier (workspace, tenant, mailbox, project).
|
|
564
212
|
|
|
565
|
-
Safe
|
|
213
|
+
Safe (globally unique ids):
|
|
566
214
|
```
|
|
567
|
-
linear:issue:<uuid>
|
|
568
|
-
github:<owner>/<repo>/issue:<number>
|
|
569
|
-
google-chat:<spaceId>:thread:<threadKey>
|
|
570
|
-
ms-teams:channel:<channelId>:message:<id>
|
|
571
|
-
ms-teams:dm:<chatId>
|
|
572
|
-
https://mail.google.com/mail/u/0/#inbox/<threadId>
|
|
215
|
+
linear:issue:<uuid>
|
|
216
|
+
github:<owner>/<repo>/issue:<number>
|
|
217
|
+
google-chat:<spaceId>:thread:<threadKey>
|
|
218
|
+
ms-teams:channel:<channelId>:message:<id>
|
|
219
|
+
ms-teams:dm:<chatId>
|
|
220
|
+
https://mail.google.com/mail/u/0/#inbox/<threadId>
|
|
573
221
|
```
|
|
574
222
|
|
|
575
|
-
|
|
223
|
+
Need disambiguation (scoped ids):
|
|
576
224
|
```
|
|
577
|
-
attio:<workspaceId>:<type>:<recordId>
|
|
578
|
-
posthog:<projectId>:person:<distinctId>
|
|
579
|
-
outlook-calendar:<mailboxId>:<eventId>
|
|
580
|
-
fellow:<tenantId>:note:<id>
|
|
225
|
+
attio:<workspaceId>:<type>:<recordId>
|
|
226
|
+
posthog:<projectId>:person:<distinctId>
|
|
227
|
+
outlook-calendar:<mailboxId>:<eventId>
|
|
228
|
+
fellow:<tenantId>:note:<id>
|
|
581
229
|
```
|
|
582
230
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
**Mutable IDs:** For services where identifiers can change (like Jira issue keys that change on project move), use the immutable ID in `source` and store the mutable key in `meta` only.
|
|
231
|
+
Pick the format up front — retrofits require a backfill migration.
|
|
586
232
|
|
|
587
|
-
|
|
233
|
+
**Mutable ids:** use the immutable id in `source`, store the mutable key in `meta` only (e.g. Jira issue id in `source`, issue key in `meta`).
|
|
588
234
|
|
|
589
|
-
|
|
235
|
+
### Attestation-based visibility
|
|
590
236
|
|
|
591
|
-
|
|
237
|
+
Populating `thread.contacts` with recipients does NOT automatically admit them. The runtime requires each user's own connector to sync the item before they get a `thread_priority` row. Users whose sync lands first go to `thread.pending_contacts` and are promoted on the next attester's sync. You just populate `contacts` with every recipient you see — the runtime's `upsert_thread` handles the rest.
|
|
592
238
|
|
|
593
|
-
## Note
|
|
239
|
+
## Note key conventions
|
|
594
240
|
|
|
595
|
-
`note.key` enables note-level upserts
|
|
241
|
+
`note.key` enables note-level upserts AND is the only way the runtime can correlate a Plot-authored note with its external counterpart for baseline preservation (see "Sync baseline preservation" above). Bidirectional connectors must assign keys on write-back.
|
|
596
242
|
|
|
597
243
|
```
|
|
598
|
-
"description"
|
|
599
|
-
"summary"
|
|
600
|
-
"metadata"
|
|
601
|
-
"cancellation"
|
|
602
|
-
"comment-<externalCommentId>"
|
|
603
|
-
"reply-<commentId>-<replyId>"
|
|
604
|
-
```
|
|
605
|
-
|
|
606
|
-
## HTML Content Handling
|
|
607
|
-
|
|
608
|
-
**Never strip HTML tags locally.** When external APIs return HTML content, pass it through with `contentType: "html"` and let the server convert it to clean markdown. Local regex-based tag stripping produces broken encoding, loses link structure, and collapses whitespace.
|
|
609
|
-
|
|
610
|
-
### Pattern
|
|
611
|
-
|
|
612
|
-
```typescript
|
|
613
|
-
// ✅ CORRECT: Pass raw HTML with contentType
|
|
614
|
-
const note = {
|
|
615
|
-
key: "description",
|
|
616
|
-
content: item.bodyHtml, // Raw HTML from API
|
|
617
|
-
contentType: "html" as const, // Server converts to markdown
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
// ✅ CORRECT: Use plain text when that's what you have
|
|
621
|
-
const note = {
|
|
622
|
-
key: "description",
|
|
623
|
-
content: item.bodyText,
|
|
624
|
-
contentType: "text" as const,
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
// ❌ WRONG: Stripping HTML locally
|
|
628
|
-
const stripped = html.replace(/<[^>]+>/g, " ").trim();
|
|
629
|
-
const note = { content: stripped }; // Broken encoding, lost links
|
|
244
|
+
"description" — main body
|
|
245
|
+
"summary" — document summary
|
|
246
|
+
"metadata" — status/priority/assignee
|
|
247
|
+
"cancellation" — cancelled event note
|
|
248
|
+
"comment-<externalCommentId>"
|
|
249
|
+
"reply-<commentId>-<replyId>"
|
|
630
250
|
```
|
|
631
251
|
|
|
632
|
-
|
|
252
|
+
## HTML content
|
|
633
253
|
|
|
634
|
-
|
|
254
|
+
**Never strip HTML locally.** Pass raw HTML with `contentType: "html"` and let the server convert to markdown (cleaner output, preserved links/encoding).
|
|
635
255
|
|
|
636
256
|
```typescript
|
|
637
|
-
|
|
638
|
-
// Prefer HTML for server-side conversion
|
|
639
|
-
const htmlPart = findPart(part, "text/html");
|
|
640
|
-
if (htmlPart) return { content: decode(htmlPart), contentType: "html" };
|
|
641
|
-
|
|
642
|
-
const textPart = findPart(part, "text/plain");
|
|
643
|
-
if (textPart) return { content: decode(textPart), contentType: "text" };
|
|
644
|
-
|
|
645
|
-
return { content: "", contentType: "text" };
|
|
646
|
-
}
|
|
257
|
+
const note = { key: "description", content: item.bodyHtml, contentType: "html" as const };
|
|
647
258
|
```
|
|
648
259
|
|
|
649
|
-
|
|
260
|
+
When both are available, prefer HTML. Use `"text"` only if no HTML is available.
|
|
650
261
|
|
|
651
|
-
|
|
262
|
+
Previews (`preview` fields) always use plain text — `snippet` or truncated title, never HTML.
|
|
652
263
|
|
|
653
|
-
|
|
264
|
+
`contentType`: `"text"` (auto-links URLs), `"markdown"` (default), `"html"` (server-converted).
|
|
654
265
|
|
|
655
|
-
|
|
656
|
-
|-------|---------|
|
|
657
|
-
| `"text"` | Plain text — auto-links URLs, preserves line breaks |
|
|
658
|
-
| `"markdown"` | Already markdown (default if omitted) |
|
|
659
|
-
| `"html"` | HTML — converted to markdown server-side |
|
|
266
|
+
## Sync metadata injection
|
|
660
267
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
**Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled):
|
|
664
|
-
|
|
665
|
-
```typescript
|
|
666
|
-
activity.meta = {
|
|
667
|
-
...activity.meta,
|
|
668
|
-
syncProvider: "myprovider", // Provider identifier
|
|
669
|
-
channelId: resourceId, // Resource being synced
|
|
670
|
-
};
|
|
671
|
-
```
|
|
672
|
-
|
|
673
|
-
This metadata is used by the twist's `onChannelDisabled` callback to match and archive activities:
|
|
268
|
+
Every synced activity must include provider and channel metadata — the twist's bulk operations (e.g. archiving on disable) rely on it:
|
|
674
269
|
|
|
675
270
|
```typescript
|
|
676
|
-
|
|
677
|
-
async onChannelDisabled(filter: ActivityFilter): Promise<void> {
|
|
678
|
-
await this.tools.plot.updateActivity({ match: filter, archived: true });
|
|
679
|
-
}
|
|
271
|
+
activity.meta = { ...activity.meta, syncProvider: "myprovider", channelId: resourceId };
|
|
680
272
|
```
|
|
681
273
|
|
|
682
|
-
## Initial vs
|
|
274
|
+
## Initial vs incremental sync (REQUIRED)
|
|
683
275
|
|
|
684
|
-
|
|
276
|
+
Missing this causes notification spam from bulk historical imports.
|
|
685
277
|
|
|
686
|
-
| Field | Initial
|
|
687
|
-
|
|
688
|
-
| `unread` | `false` | *omit* |
|
|
689
|
-
| `archived` | `false` | *omit* |
|
|
278
|
+
| Field | Initial | Incremental |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| `unread` | `false` | *omit* |
|
|
281
|
+
| `archived` | `false` | *omit* |
|
|
690
282
|
|
|
691
283
|
```typescript
|
|
692
284
|
const activity = {
|
|
693
|
-
|
|
694
|
-
...(initialSync ? { unread: false } : {}),
|
|
695
|
-
...(initialSync ? { archived: false } : {}),
|
|
285
|
+
...(initialSync ? { unread: false, archived: false } : {}),
|
|
696
286
|
};
|
|
697
287
|
```
|
|
698
288
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
The `initialSync` flag must flow from the entry point (`onChannelEnabled` / `startSync`) through every batch to the point where activities are created. There are two patterns:
|
|
702
|
-
|
|
703
|
-
**Pattern A: Store in SyncState** (used in the scaffold above)
|
|
704
|
-
|
|
705
|
-
The scaffold's `SyncState` type includes `initialSync: boolean`. Set it to `true` in `startBatchSync`, read it in `syncBatch`, and preserve it across batches. Webhook/incremental handlers pass `false`.
|
|
289
|
+
The flag must flow from entry point through every batch to the activity-creation site.
|
|
706
290
|
|
|
707
|
-
**Pattern
|
|
291
|
+
- **Pattern A — store in SyncState**: include `initialSync: boolean` in your state type; set `true` in `startBatchSync`, preserve across batches, webhooks pass `false`.
|
|
292
|
+
- **Pattern B — pass as callback arg**: make it the last, optional param (for backward compat) and propagate: `async syncBatch(batch: number, mode: "full"|"incremental", channelId: string, initialSync?: boolean)`. Used by connectors like Gmail.
|
|
708
293
|
|
|
709
|
-
|
|
294
|
+
Entry points: `onChannelEnabled`/`startSync` → `true`; webhook/incremental → `false`; next batch → propagate current value.
|
|
710
295
|
|
|
711
|
-
|
|
712
|
-
// onChannelEnabled — initial sync
|
|
713
|
-
const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true);
|
|
714
|
-
|
|
715
|
-
// startIncrementalSync — not initial
|
|
716
|
-
const syncCallback = await this.callback(this.syncBatch, 1, "incremental", channelId, false);
|
|
717
|
-
|
|
718
|
-
// syncBatch — accept and propagate the flag
|
|
719
|
-
async syncBatch(
|
|
720
|
-
batchNumber: number,
|
|
721
|
-
mode: "full" | "incremental",
|
|
722
|
-
channelId: string,
|
|
723
|
-
initialSync?: boolean // optional for backward compat with old serialized callbacks
|
|
724
|
-
): Promise<void> {
|
|
725
|
-
const isInitial = initialSync ?? (mode === "full"); // safe default for old callbacks
|
|
726
|
-
// ... pass isInitial to processItems and to next batch callback
|
|
727
|
-
}
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
**Whichever pattern you use, verify that ALL entry points set the flag correctly:**
|
|
731
|
-
- `onChannelEnabled` → `true` (first import)
|
|
732
|
-
- `startSync` → `true` (manual full sync)
|
|
733
|
-
- Webhook / incremental handler → `false`
|
|
734
|
-
- Next batch callback → propagate current value
|
|
735
|
-
|
|
736
|
-
## Webhook Patterns
|
|
296
|
+
## Webhooks
|
|
737
297
|
|
|
738
|
-
### Localhost
|
|
739
|
-
|
|
740
|
-
All connectors MUST skip webhook registration in local development:
|
|
298
|
+
### Localhost guard (REQUIRED)
|
|
741
299
|
|
|
742
300
|
```typescript
|
|
743
301
|
const webhookUrl = await this.tools.network.createWebhook({}, this.onWebhook, resourceId);
|
|
744
|
-
|
|
745
|
-
if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
|
|
746
|
-
return; // Skip — webhooks can't reach localhost
|
|
747
|
-
}
|
|
302
|
+
if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) return;
|
|
748
303
|
```
|
|
749
304
|
|
|
750
|
-
###
|
|
751
|
-
|
|
752
|
-
Verify webhook signatures to prevent unauthorized calls. Each provider has its own method:
|
|
305
|
+
### Signature verification
|
|
753
306
|
|
|
754
307
|
| Provider | Method |
|
|
755
|
-
|
|
308
|
+
|---|---|
|
|
756
309
|
| Linear | `LinearWebhookClient` from `@linear/sdk/webhooks` |
|
|
757
|
-
| Slack | Challenge response + event type
|
|
310
|
+
| Slack | Challenge response + event type filter |
|
|
758
311
|
| Google | UUID secret in channel token query |
|
|
759
312
|
| Microsoft | Subscription `clientState` |
|
|
760
313
|
| Asana | HMAC-SHA256 via `crypto.subtle` |
|
|
761
314
|
|
|
762
|
-
### Watch
|
|
763
|
-
|
|
764
|
-
For providers that expire watches, schedule proactive renewal:
|
|
315
|
+
### Watch renewal (Calendar/Drive)
|
|
765
316
|
|
|
766
317
|
```typescript
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const renewalCallback = await this.callback(this.renewWatch, resourceId);
|
|
772
|
-
const taskToken = await this.runTask(renewalCallback, { runAt: renewalTime });
|
|
773
|
-
if (taskToken) await this.set(`watch_renewal_task_${resourceId}`, taskToken);
|
|
774
|
-
}
|
|
318
|
+
const renewalTime = new Date(expiresAt.getTime() - 24 * 60 * 60 * 1000);
|
|
319
|
+
const renewal = await this.callback(this.renewWatch, resourceId);
|
|
320
|
+
const taskToken = await this.runTask(renewal, { runAt: renewalTime });
|
|
321
|
+
if (taskToken) await this.set(`watch_renewal_task_${resourceId}`, taskToken);
|
|
775
322
|
```
|
|
776
323
|
|
|
777
|
-
## Bidirectional
|
|
778
|
-
|
|
779
|
-
For connectors that support write-backs (updating external items from Plot):
|
|
780
|
-
|
|
781
|
-
### Issue/Task Updates (`updateIssue`)
|
|
324
|
+
## Bidirectional sync
|
|
782
325
|
|
|
783
326
|
```typescript
|
|
327
|
+
// Update issue/task from Plot
|
|
784
328
|
async updateIssue(activity: Activity): Promise<void> {
|
|
785
329
|
const externalId = activity.meta?.externalId as string;
|
|
786
|
-
if (!externalId) throw new Error("External ID not found in meta");
|
|
787
|
-
|
|
788
330
|
const client = await this.getClient(activity.meta?.resourceId as string);
|
|
789
|
-
await client.updateItem(externalId, {
|
|
790
|
-
title: activity.title,
|
|
791
|
-
done: activity.type === ActivityType.Action ? activity.done : undefined,
|
|
792
|
-
});
|
|
331
|
+
await client.updateItem(externalId, { title: activity.title, done: /* ... */ });
|
|
793
332
|
}
|
|
794
|
-
```
|
|
795
|
-
|
|
796
|
-
### Comment Sync (`addIssueComment`)
|
|
797
333
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
334
|
+
// Post a comment. Return a NoteWriteBackResult with externalContent so the
|
|
335
|
+
// runtime can establish a sync baseline (see "Sync baseline preservation"
|
|
336
|
+
// below). Returning just a string still works, but without a baseline the
|
|
337
|
+
// next sync-in will overwrite Plot's (possibly richer-markdown) content
|
|
338
|
+
// with the round-tripped plain text the external system stored.
|
|
339
|
+
async onNoteCreated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {
|
|
340
|
+
const client = await this.getClient(thread.meta?.resourceId as string);
|
|
341
|
+
const comment = await client.createComment(thread.meta?.externalId as string, { body: note.content ?? "" });
|
|
342
|
+
if (!comment?.id) return;
|
|
343
|
+
return {
|
|
344
|
+
key: `comment-${comment.id}`,
|
|
345
|
+
externalContent: comment.body, // what the external system NOW STORES
|
|
346
|
+
};
|
|
347
|
+
}
|
|
802
348
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
349
|
+
// Push a local edit back to the external system. `note.key` identifies the
|
|
350
|
+
// target; refresh the baseline from the response so the next sync-in
|
|
351
|
+
// recognises the round-trip and preserves Plot's edited content.
|
|
352
|
+
async onNoteUpdated(note: Note, thread: Thread): Promise<NoteWriteBackResult | void> {
|
|
353
|
+
if (!note.key?.startsWith("comment-")) return;
|
|
354
|
+
const commentId = note.key.slice("comment-".length);
|
|
355
|
+
const client = await this.getClient(thread.meta?.resourceId as string);
|
|
356
|
+
const updated = await client.updateComment(commentId, { body: note.content ?? "" });
|
|
357
|
+
return { externalContent: updated.body };
|
|
806
358
|
}
|
|
807
359
|
```
|
|
808
360
|
|
|
809
|
-
|
|
361
|
+
**Loop prevention** (in the twist, not the connector): `if (note.author.type === ActorType.Twist) return;`
|
|
362
|
+
|
|
363
|
+
**`handleReplies`**: bidirectional connectors must set `static readonly handleReplies = true` to enable @-mentions on replies. Read-only connectors should NOT.
|
|
364
|
+
|
|
365
|
+
### Sync baseline preservation (required for any note round-trip)
|
|
366
|
+
|
|
367
|
+
When Plot pushes a note to an external system that stores content in a lossier format than Plot does (e.g. plain-text comments APIs, ADF, HTML that gets sanitised), the external's version re-ingested on the next sync would overwrite Plot's original content with the round-tripped form — `1.` → `1\.`, `[name]` → `\[name\]`, etc. To prevent this, the runtime tracks a per-note "external baseline" hash in `note.external_content_hash`:
|
|
368
|
+
|
|
369
|
+
- **Write-back (`onNoteCreated` / `onNoteUpdated`) returns `NoteWriteBackResult`** with `externalContent` set to what the external system now stores. The runtime hashes this and stores it as the note's baseline. (The hash is over the content string only — no contentType prefix — so write-back and sync-in don't need to agree on a contentType label to match.)
|
|
370
|
+
- **Sync-in** computes the same hash on each incoming `NewNote.content`. Match → preserve Plot's stored content (skip overwrite). Mismatch → external was edited, overwrite.
|
|
371
|
+
|
|
372
|
+
The hard contract:
|
|
373
|
+
|
|
374
|
+
> **`externalContent` must exactly equal the `NewNote.content` string your sync-in path will emit for this note on re-ingest.**
|
|
375
|
+
|
|
376
|
+
If your sync-in runs incoming comment bodies through a transform (e.g. Airtable's `translateMentionsInbound`, Jira's `extractTextFromADF`), apply the same transform to the write-back response before returning it. Pick the value by looking at the sync-in `build*Note` function and returning exactly what ends up in `content`.
|
|
377
|
+
|
|
378
|
+
For systems that return the stored representation on write (Drive, GitHub, Linear, Slack `chat.postMessage`), use the response directly. For systems that don't (Gmail `messages.send`), either fetch the stored form with an extra API call or skip `externalContent` — the first sync-in after the write will organically establish the baseline. Document the tradeoff.
|
|
810
379
|
|
|
811
|
-
|
|
380
|
+
**Failure modes you must avoid:**
|
|
381
|
+
|
|
382
|
+
- Returning `externalContent` that differs from what sync-in will produce → every sync-in clobbers Plot's content.
|
|
383
|
+
- Not setting `handleReplies = true` → dispatch never reaches `onNoteCreated`.
|
|
384
|
+
- Calling `integrations.saveLink()` inside `onNoteCreated` to propagate the key → no longer needed (runtime now does it). Remove any such workaround.
|
|
385
|
+
|
|
386
|
+
**Legacy:** `onNoteCreated` may still return a plain `string` — treated as `{ key }` with no baseline. Don't use this shape in new connectors.
|
|
387
|
+
|
|
388
|
+
### Creating new items from Plot (`onCreateLink`)
|
|
389
|
+
|
|
390
|
+
Opt a link type in by marking one status with `createDefault: true`:
|
|
812
391
|
|
|
813
392
|
```typescript
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
393
|
+
statuses: [
|
|
394
|
+
{ status: "backlog", label: "Backlog" },
|
|
395
|
+
{ status: "unstarted", label: "To Do", todo: true, createDefault: true },
|
|
396
|
+
{ status: "completed", label: "Done", tag: Tag.Done, done: true },
|
|
397
|
+
]
|
|
819
398
|
```
|
|
820
399
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
Connectors with bidirectional sync should set `static readonly handleReplies = true` so replies to synced threads automatically mention the connector:
|
|
400
|
+
Then implement `onCreateLink` — return the link, do NOT call `integrations.saveLink()` (platform wires it to the originating thread):
|
|
824
401
|
|
|
825
402
|
```typescript
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
403
|
+
async onCreateLink(draft: CreateLinkDraft): Promise<NewLinkWithNotes | null> {
|
|
404
|
+
if (draft.type !== "issue") return null;
|
|
405
|
+
const client = await this.getClient(draft.channelId);
|
|
406
|
+
const payload = await client.createIssue({
|
|
407
|
+
teamId: draft.channelId,
|
|
408
|
+
title: draft.title,
|
|
409
|
+
description: draft.noteContent ?? undefined,
|
|
410
|
+
stateId: await this.resolveStateId(client, draft.channelId, draft.status),
|
|
411
|
+
});
|
|
412
|
+
const issue = await payload.issue;
|
|
413
|
+
if (!issue) return null;
|
|
414
|
+
return {
|
|
415
|
+
source: `linear:issue:${issue.id}`,
|
|
416
|
+
type: "issue",
|
|
417
|
+
title: issue.title,
|
|
418
|
+
status: draft.status,
|
|
419
|
+
created: issue.createdAt,
|
|
420
|
+
sourceUrl: issue.url ?? null,
|
|
421
|
+
meta: { linearId: issue.id, projectId: draft.channelId },
|
|
422
|
+
// channelId/type default to draft values if omitted
|
|
423
|
+
};
|
|
829
424
|
}
|
|
830
425
|
```
|
|
831
426
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
### Creating New Items from Plot (`onCreateLink`)
|
|
835
|
-
|
|
836
|
-
Plot users can start a new thread tied to a brand-new external item (e.g. create a Linear issue, a Google Calendar event, a Slack DM) via "Create new …" in the Add link modal. Connectors opt in per link type:
|
|
837
|
-
|
|
838
|
-
1. **Mark a status as the creation default** on the `LinkTypeConfig` you expose for that type — either on the static `readonly linkTypes` on the class, or on the dynamic per-channel linkTypes returned by `getChannels`:
|
|
839
|
-
|
|
840
|
-
```typescript
|
|
841
|
-
statuses: [
|
|
842
|
-
{ status: "backlog", label: "Backlog" },
|
|
843
|
-
{ status: "unstarted", label: "To Do", todo: true, createDefault: true },
|
|
844
|
-
{ status: "completed", label: "Done", tag: Tag.Done, done: true },
|
|
845
|
-
],
|
|
846
|
-
```
|
|
847
|
-
|
|
848
|
-
A link type opts in to Plot-initiated creation by declaring at least one status with `createDefault: true`. The marked status is used as the default when the user selects "Create new X".
|
|
849
|
-
|
|
850
|
-
2. **Implement `onCreateLink(draft)`** — called after the Plot thread is saved and titled. Create the external item and return a `NewLinkWithNotes` describing it. The platform attaches the link to the originating thread; do NOT call `integrations.saveLink()` yourself.
|
|
851
|
-
|
|
852
|
-
```typescript
|
|
853
|
-
async onCreateLink(draft: CreateLinkDraft): Promise<NewLinkWithNotes | null> {
|
|
854
|
-
if (draft.type !== "issue") return null;
|
|
855
|
-
const client = await this.getClient(draft.channelId);
|
|
856
|
-
const payload = await client.createIssue({
|
|
857
|
-
teamId: draft.channelId,
|
|
858
|
-
title: draft.title,
|
|
859
|
-
description: draft.noteContent ?? undefined,
|
|
860
|
-
stateId: await this.resolveStateId(client, draft.channelId, draft.status),
|
|
861
|
-
});
|
|
862
|
-
const issue = await payload.issue;
|
|
863
|
-
if (!issue) return null;
|
|
864
|
-
return {
|
|
865
|
-
source: `linear:issue:${issue.id}`,
|
|
866
|
-
type: "issue",
|
|
867
|
-
title: issue.title,
|
|
868
|
-
status: draft.status,
|
|
869
|
-
created: issue.createdAt,
|
|
870
|
-
sourceUrl: issue.url ?? null,
|
|
871
|
-
meta: { linearId: issue.id, projectId: draft.channelId },
|
|
872
|
-
// channelId/type default to draft.channelId/draft.type if you omit them.
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
```
|
|
876
|
-
|
|
877
|
-
**`CreateLinkDraft` shape** (see `twister/src/connector.ts`):
|
|
878
|
-
- `channelId`, `type`, `status` — identify the target channel + link type + status.
|
|
879
|
-
- `title` — Plot thread title (post AI title generation).
|
|
880
|
-
- `noteContent` — markdown of the thread's first note, or `null`.
|
|
881
|
-
- `contacts: Actor[]` — thread's contacts (excluding the creating user), for email recipients / DM members / invitees.
|
|
882
|
-
|
|
883
|
-
**Platform defaults**: the runtime fills in `channelId` and `type` on the returned link from the draft if the connector omits them. Status label resolution depends on `channel_id`, so this default keeps the UI rendering correct even if you forget to echo them.
|
|
884
|
-
|
|
885
|
-
**Do not**:
|
|
886
|
-
- Call `integrations.saveLink()` — the platform wires the returned link to the user's thread.
|
|
887
|
-
- Assume the draft's status matches an external state id verbatim. For dynamic-per-team statuses (Linear teams, Jira projects), the draft's status is whatever was shown in the picker — your connector is responsible for resolving categories like `"unstarted"` if your static `linkTypes` fallback was used.
|
|
888
|
-
|
|
889
|
-
**Loop prevention**: the link your `onCreateLink` returns is written with `updated_by` set to the twist, so subsequent syncs of the same external id won't retrigger `onCreateLink` or `onLinkUpdated` for the initial state.
|
|
890
|
-
|
|
891
|
-
## Contacts Pattern
|
|
892
|
-
|
|
893
|
-
Connectors that sync user data should create contacts for authors and assignees:
|
|
427
|
+
`CreateLinkDraft`: `channelId`, `type`, `status`, `title`, `noteContent`, `contacts: Actor[]`. See `twister/src/connector.ts`.
|
|
894
428
|
|
|
895
|
-
|
|
896
|
-
|
|
429
|
+
Resolve category statuses (`"unstarted"`, etc.) to the provider's state id yourself — the draft's status is whatever the picker showed.
|
|
430
|
+
|
|
431
|
+
The returned link is written with `updated_by` set to the twist, so subsequent syncs of the same id won't re-fire `onCreateLink`/`onLinkUpdated` for the initial state.
|
|
432
|
+
|
|
433
|
+
## Contacts
|
|
897
434
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
435
|
+
Contacts are created implicitly when you save threads/links — no `addContacts()` call, no `ContactAccess.Write`.
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const author: NewContact | undefined = creator?.email
|
|
439
|
+
? { email: creator.email, name: creator.name, avatar: creator.avatarUrl ?? undefined }
|
|
440
|
+
: undefined;
|
|
903
441
|
|
|
904
442
|
const activity: NewActivityWithNotes = {
|
|
905
|
-
|
|
906
|
-
author: authorContact,
|
|
443
|
+
author,
|
|
907
444
|
assignee: assigneeContact ?? null,
|
|
908
|
-
notes: [{
|
|
909
|
-
author: authorContact, // Note-level author too
|
|
910
|
-
// ...
|
|
911
|
-
}],
|
|
445
|
+
notes: [{ author /* note-level author too */ }],
|
|
912
446
|
};
|
|
913
447
|
```
|
|
914
448
|
|
|
915
|
-
|
|
449
|
+
## Buffer declaration
|
|
916
450
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
Cloudflare Workers provides `Buffer` globally, but TypeScript doesn't know about it. Declare it at the top of files that need it:
|
|
451
|
+
Cloudflare Workers provide `Buffer` globally but TS doesn't know. Add at file top:
|
|
920
452
|
|
|
921
453
|
```typescript
|
|
922
454
|
declare const Buffer: {
|
|
923
|
-
from(
|
|
924
|
-
|
|
925
|
-
encoding?: string
|
|
926
|
-
): Uint8Array & { toString(encoding?: string): string };
|
|
455
|
+
from(data: string | ArrayBuffer | Uint8Array, encoding?: string):
|
|
456
|
+
Uint8Array & { toString(encoding?: string): string };
|
|
927
457
|
};
|
|
928
458
|
```
|
|
929
459
|
|
|
930
|
-
##
|
|
460
|
+
## Build & test
|
|
931
461
|
|
|
932
462
|
```bash
|
|
933
|
-
# Build the connector
|
|
934
463
|
cd public/connectors/<name> && pnpm build
|
|
935
|
-
|
|
936
|
-
# Type-check without building
|
|
937
464
|
cd public/connectors/<name> && pnpm exec tsc --noEmit
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
- [ ]
|
|
948
|
-
- [ ]
|
|
949
|
-
- [ ]
|
|
950
|
-
- [ ]
|
|
951
|
-
- [ ]
|
|
952
|
-
- [ ]
|
|
953
|
-
- [ ]
|
|
954
|
-
- [ ]
|
|
955
|
-
- [ ]
|
|
956
|
-
- [ ]
|
|
957
|
-
- [ ]
|
|
958
|
-
- [ ]
|
|
959
|
-
- [ ]
|
|
960
|
-
- [ ]
|
|
961
|
-
- [ ]
|
|
962
|
-
- [ ]
|
|
963
|
-
- [ ]
|
|
964
|
-
- [ ]
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
|
996
|
-
|
|
997
|
-
| `
|
|
998
|
-
| `
|
|
999
|
-
| `
|
|
1000
|
-
| `
|
|
1001
|
-
| `google-
|
|
1002
|
-
| `jira/` | ProjectConnector | Immutable vs mutable IDs, comment metadata for dedup |
|
|
1003
|
-
| `asana/` | ProjectConnector | HMAC webhook verification, section-based projects |
|
|
1004
|
-
| `outlook-calendar/` | CalendarConnector | Microsoft Graph API, subscription management |
|
|
1005
|
-
| `google-contacts/` | (Supporting) | Contact sync, cross-connector `syncWithAuth()` pattern |
|
|
465
|
+
pnpm install # from repo root
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Add to `pnpm-workspace.yaml` if not already covered by a glob.
|
|
469
|
+
|
|
470
|
+
## Checklist
|
|
471
|
+
|
|
472
|
+
- [ ] Extend `Connector<YourConnector>`; declare `PROVIDER`, `SCOPES`, `Options` (static + `declare readonly`)
|
|
473
|
+
- [ ] `build()` declares Integrations, Network, Callbacks, Tasks (plus GoogleContacts if applicable)
|
|
474
|
+
- [ ] Set `handleReplies = true` only if bidirectional
|
|
475
|
+
- [ ] For bidirectional note sync: `onNoteCreated` / `onNoteUpdated` return `NoteWriteBackResult` with `externalContent` matching what sync-in will emit for this note — no `Promise<string | void>` in new connectors
|
|
476
|
+
- [ ] For bidirectional note sync: implement `onNoteUpdated` if the external supports editing (document the gap if it doesn't)
|
|
477
|
+
- [ ] `onChannelEnabled` uses `runTask()` (NOT `run()`) for webhook setup and initial sync — blocks HTTP response otherwise
|
|
478
|
+
- [ ] Convert parent callbacks to tokens with `createFromParent()`; store via `this.set()`; retrieve with `this.get<Callback>()`
|
|
479
|
+
- [ ] Never pass functions, RPC stubs, or `undefined` to `this.callback()` — use `null`
|
|
480
|
+
- [ ] Validate token exists before `callbacks.run()`
|
|
481
|
+
- [ ] Localhost guard in webhook setup; verify webhook signatures
|
|
482
|
+
- [ ] Canonical, globally-unique `source` using immutable ids; mutable keys in `meta` only
|
|
483
|
+
- [ ] `note.key` for note-level upserts
|
|
484
|
+
- [ ] Inject `syncProvider` + `channelId` into `activity.meta`
|
|
485
|
+
- [ ] `contentType: "html"` for HTML — never strip tags locally
|
|
486
|
+
- [ ] `created` on notes = external timestamp, not sync time
|
|
487
|
+
- [ ] `initialSync` propagated through every entry point and batch; set `unread: false, archived: false` on initial, omit on incremental
|
|
488
|
+
- [ ] Create `NewContact` for authors/assignees
|
|
489
|
+
- [ ] Clean up callbacks, webhooks, stored state in `stopSync()` and `onChannelDisabled()`
|
|
490
|
+
- [ ] For Plot-initiated creation: mark status with `createDefault: true` AND implement `onCreateLink` — don't call `saveLink` from inside it
|
|
491
|
+
- [ ] `pnpm build` succeeds
|
|
492
|
+
|
|
493
|
+
## Common pitfalls
|
|
494
|
+
|
|
495
|
+
1. Passing functions/`undefined`/RPC stubs to `this.callback()` → use tokens + `null`.
|
|
496
|
+
2. Forgetting sync metadata (`syncProvider`, `channelId`) → breaks bulk archive on disable.
|
|
497
|
+
3. Not propagating `initialSync` through the whole pipeline → notification spam.
|
|
498
|
+
4. Mutable ids in `source` (e.g. Jira issue key) → use immutable id, store key in `meta`.
|
|
499
|
+
5. `source` that's only unique within one user's account → breaks cross-user dedup; add workspace/tenant/mailbox qualifier.
|
|
500
|
+
6. Not breaking long loops into batches → each execution has ~1000 request limit.
|
|
501
|
+
7. Missing localhost guard → webhook registration fails silently.
|
|
502
|
+
8. Calling `plot.createThread()` from a connector → use `integrations.saveLink()`.
|
|
503
|
+
9. Breaking callback signatures → add optional params at end only; use `preUpgrade()` for breaking migrations.
|
|
504
|
+
10. Not cleaning up on disable → orphan callbacks, webhooks, state.
|
|
505
|
+
11. Two-way sync without metadata correlation → embed Plot id in external metadata to prevent race-condition duplicates (see SYNC_STRATEGIES.md §6).
|
|
506
|
+
12. Stripping HTML locally → breaks encoding + loses links; use `contentType: "html"`.
|
|
507
|
+
13. Placeholder titles in comment/update webhooks → `title` overwrites on upsert; fetch the real title if webhook doesn't carry it.
|
|
508
|
+
14. Omitting `created` on notes → everything appears "just now"; pass external timestamp.
|
|
509
|
+
15. `this.run()` in `onChannelEnabled` → blocks HTTP response until full sync completes; always `runTask()`.
|
|
510
|
+
16. Calling `integrations.saveLink()` inside `onCreateLink` → duplicate thread; just return the link.
|
|
511
|
+
17. Implementing `onCreateLink` without `createDefault: true` on a status → "Create new X" entry never appears.
|
|
512
|
+
18. Returning a bare `string` (key only) from `onNoteCreated` when the external stores content lossily (plain-text comments APIs, ADF, sanitised HTML) → next sync-in clobbers Plot's content with the round-tripped form (e.g. `1.` → `1\.`). Return a `NoteWriteBackResult` with `externalContent` matching sync-in's shape instead.
|
|
513
|
+
19. Returning `externalContent` that doesn't match what sync-in emits for the same note (e.g. post-write raw HTML when sync-in extracts plain text; pre-translation mentions when sync-in translates them) → baseline hash always mismatches and every sync clobbers. Inspect the sync-in `build*Note` path and return exactly what it produces.
|
|
514
|
+
20. Calling `integrations.saveLink()` inside `onNoteCreated` to set the note's `key` → legacy workaround, no longer needed. The runtime sets `key` automatically from the `NoteWriteBackResult` return.
|
|
515
|
+
|
|
516
|
+
## Examples
|
|
517
|
+
|
|
518
|
+
| Connector | Category | Key patterns |
|
|
519
|
+
|---|---|---|
|
|
520
|
+
| `linear/` | ProjectConnector | Canonical reference; webhooks; bidirectional |
|
|
521
|
+
| `google-calendar/` | CalendarConnector | Recurring events; RSVP write-back; watch renewal; shared Google auth |
|
|
522
|
+
| `slack/` | MessagingConnector | Team-sharded webhooks; thread model |
|
|
523
|
+
| `gmail/` | MessagingConnector | PubSub webhooks; HTML contentType; callback-arg `initialSync` |
|
|
524
|
+
| `google-drive/` | DocumentConnector | Document comments; reply threading; file watching; canonical `NoteWriteBackResult` + `onNoteUpdated` example |
|
|
525
|
+
| `jira/` | ProjectConnector | Immutable vs mutable ids; comment metadata dedup |
|
|
526
|
+
| `asana/` | ProjectConnector | HMAC webhook verification; section-based projects |
|
|
527
|
+
| `outlook-calendar/` | CalendarConnector | Microsoft Graph; subscription management |
|
|
528
|
+
| `google-contacts/` | Supporting | Contact sync; cross-connector `syncWithAuth()` |
|