@plotday/twister 0.47.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.
Files changed (123) hide show
  1. package/bin/commands/generate.js +5 -5
  2. package/bin/commands/generate.js.map +1 -1
  3. package/bin/templates/AGENTS.template.md +8 -2
  4. package/bin/utils/bundle.js +14 -0
  5. package/bin/utils/bundle.js.map +1 -1
  6. package/cli/templates/AGENTS.template.md +8 -2
  7. package/dist/connector.d.ts +67 -7
  8. package/dist/connector.d.ts.map +1 -1
  9. package/dist/connector.js +15 -5
  10. package/dist/connector.js.map +1 -1
  11. package/dist/docs/assets/hierarchy.js +1 -1
  12. package/dist/docs/assets/navigation.js +1 -1
  13. package/dist/docs/assets/search.js +1 -1
  14. package/dist/docs/classes/index.Connector.html +58 -49
  15. package/dist/docs/classes/index.Imap.html +1 -1
  16. package/dist/docs/classes/index.Options.html +1 -1
  17. package/dist/docs/classes/index.Smtp.html +1 -1
  18. package/dist/docs/classes/tools_ai.AI.html +1 -1
  19. package/dist/docs/classes/tools_callbacks.Callbacks.html +1 -1
  20. package/dist/docs/classes/tools_integrations.Integrations.html +21 -5
  21. package/dist/docs/classes/tools_network.Network.html +1 -1
  22. package/dist/docs/classes/tools_plot.Plot.html +1 -1
  23. package/dist/docs/classes/tools_store.Store.html +1 -1
  24. package/dist/docs/classes/tools_tasks.Tasks.html +1 -1
  25. package/dist/docs/classes/tools_twists.Twists.html +1 -1
  26. package/dist/docs/classes/twist.Twist.html +28 -28
  27. package/dist/docs/documents/Building_Connectors.html +8 -1
  28. package/dist/docs/documents/CLI_Reference.html +6 -4
  29. package/dist/docs/enums/tag.Tag.html +11 -1
  30. package/dist/docs/enums/tools_integrations.AuthProvider.html +14 -12
  31. package/dist/docs/hierarchy.html +1 -1
  32. package/dist/docs/media/AGENTS.md +298 -775
  33. package/dist/docs/media/MULTI_USER_AUTH.md +6 -4
  34. package/dist/docs/media/SYNC_STRATEGIES.md +20 -14
  35. package/dist/docs/modules/index.html +1 -1
  36. package/dist/docs/types/index.CreateLinkDraft.html +7 -12
  37. package/dist/docs/types/index.NoteWriteBackResult.html +38 -0
  38. package/dist/docs/types/tools_integrations.ArchiveLinkFilter.html +5 -5
  39. package/dist/docs/types/tools_integrations.AuthToken.html +4 -4
  40. package/dist/docs/types/tools_integrations.Authorization.html +4 -4
  41. package/dist/llm-docs/connector.d.ts +1 -1
  42. package/dist/llm-docs/connector.d.ts.map +1 -1
  43. package/dist/llm-docs/connector.js +1 -1
  44. package/dist/llm-docs/connector.js.map +1 -1
  45. package/dist/llm-docs/tag.d.ts +1 -1
  46. package/dist/llm-docs/tag.d.ts.map +1 -1
  47. package/dist/llm-docs/tag.js +1 -1
  48. package/dist/llm-docs/tag.js.map +1 -1
  49. package/dist/llm-docs/tools/integrations.d.ts +1 -1
  50. package/dist/llm-docs/tools/integrations.d.ts.map +1 -1
  51. package/dist/llm-docs/tools/integrations.js +1 -1
  52. package/dist/llm-docs/tools/integrations.js.map +1 -1
  53. package/dist/llm-docs/twist-guide-template.d.ts +1 -1
  54. package/dist/llm-docs/twist-guide-template.d.ts.map +1 -1
  55. package/dist/llm-docs/twist-guide-template.js +1 -1
  56. package/dist/llm-docs/twist-guide-template.js.map +1 -1
  57. package/dist/llm-docs/twist.d.ts +1 -1
  58. package/dist/llm-docs/twist.d.ts.map +1 -1
  59. package/dist/llm-docs/twist.js +1 -1
  60. package/dist/llm-docs/twist.js.map +1 -1
  61. package/dist/tag.d.ts +11 -1
  62. package/dist/tag.d.ts.map +1 -1
  63. package/dist/tag.js +10 -0
  64. package/dist/tag.js.map +1 -1
  65. package/dist/tools/integrations.d.ts +25 -1
  66. package/dist/tools/integrations.d.ts.map +1 -1
  67. package/dist/tools/integrations.js +2 -0
  68. package/dist/tools/integrations.js.map +1 -1
  69. package/dist/twist-guide.d.ts +1 -1
  70. package/dist/twist-guide.d.ts.map +1 -1
  71. package/dist/twist.d.ts +2 -1
  72. package/dist/twist.d.ts.map +1 -1
  73. package/dist/twist.js.map +1 -1
  74. package/dist/utils/markdown.d.ts +27 -0
  75. package/dist/utils/markdown.d.ts.map +1 -0
  76. package/dist/utils/markdown.js +82 -0
  77. package/dist/utils/markdown.js.map +1 -0
  78. package/package.json +7 -1
  79. package/src/connector.ts +427 -0
  80. package/src/creator-docs.ts +29 -0
  81. package/src/index.ts +10 -0
  82. package/src/llm-docs/connector.ts +8 -0
  83. package/src/llm-docs/index.ts +48 -0
  84. package/src/llm-docs/options.ts +8 -0
  85. package/src/llm-docs/plot.ts +8 -0
  86. package/src/llm-docs/schedule.ts +8 -0
  87. package/src/llm-docs/tag.ts +8 -0
  88. package/src/llm-docs/tool.ts +8 -0
  89. package/src/llm-docs/tools/ai.ts +8 -0
  90. package/src/llm-docs/tools/callbacks.ts +8 -0
  91. package/src/llm-docs/tools/imap.ts +8 -0
  92. package/src/llm-docs/tools/integrations.ts +8 -0
  93. package/src/llm-docs/tools/network.ts +8 -0
  94. package/src/llm-docs/tools/plot.ts +8 -0
  95. package/src/llm-docs/tools/smtp.ts +8 -0
  96. package/src/llm-docs/tools/store.ts +8 -0
  97. package/src/llm-docs/tools/tasks.ts +8 -0
  98. package/src/llm-docs/tools/twists.ts +8 -0
  99. package/src/llm-docs/twist-guide-template.ts +8 -0
  100. package/src/llm-docs/twist.ts +8 -0
  101. package/src/options.ts +115 -0
  102. package/src/plot.ts +1068 -0
  103. package/src/schedule.ts +203 -0
  104. package/src/tag.ts +54 -0
  105. package/src/tool.ts +377 -0
  106. package/src/tools/ai.ts +845 -0
  107. package/src/tools/callbacks.ts +134 -0
  108. package/src/tools/imap.ts +266 -0
  109. package/src/tools/index.ts +10 -0
  110. package/src/tools/integrations.ts +352 -0
  111. package/src/tools/network.ts +240 -0
  112. package/src/tools/plot.ts +692 -0
  113. package/src/tools/smtp.ts +166 -0
  114. package/src/tools/store.ts +149 -0
  115. package/src/tools/tasks.ts +137 -0
  116. package/src/tools/twists.ts +228 -0
  117. package/src/twist-guide.ts +9 -0
  118. package/src/twist.ts +436 -0
  119. package/src/utils/hash.ts +8 -0
  120. package/src/utils/markdown.ts +94 -0
  121. package/src/utils/serializable.ts +54 -0
  122. package/src/utils/types.ts +130 -0
  123. package/src/utils/uuid.ts +9 -0
@@ -1,28 +1,22 @@
1
1
  # Connector Development Guide
2
2
 
3
- This guide covers everything needed to build a Plot connector correctly.
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
- **For twist development**: See `../twister/cli/templates/AGENTS.template.md`
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 # Re-exports: export { default, ClassName } from "./class-file"
17
- <class-name>.ts # Main Connector class
18
- <api-name>.ts # (optional) Separate API client + transform functions
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
- "build": "tsc",
49
- "clean": "rm -rf dist"
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
- **Notes:**
68
- - `"@plotday/connector"` export condition resolves to TypeScript source during workspace development
69
- - Add third-party SDKs to `dependencies` (e.g., `"@linear/sdk": "^72.0.0"`)
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
- ### src/index.ts
86
-
87
- ```typescript
88
- export { default, ConnectorName } from "./connector-name";
89
- ```
65
+ ## Class skeleton
90
66
 
91
- ## Connector Class Template
67
+ Use `connectors/linear/` as the canonical reference. Minimum shape:
92
68
 
93
69
  ```typescript
94
70
  import {
95
- ActivityType,
96
- LinkType,
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
- // 1. Static constants
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; // Enable @-mentions on replies to synced threads
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
- // 3. Create API client using channel-based auth
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
- await this.set(`sync_enabled_${channel.id}`, true);
166
-
167
- // Store parent callback tokens
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
- await this.stopSync(channel.id);
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
- ## The Integrations Pattern (Auth + Channels)
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
- `AuthProvider` enum: `Google`, `Microsoft`, `Notion`, `Slack`, `Atlassian`, `Linear`, `Monday`, `GitHub`, `Asana`, `HubSpot`.
124
+ ## Integrations (auth + channels)
395
125
 
396
- ### Per-User Auth for Write-Backs
126
+ Auth is handled in the Flutter edit modal — you declare providers in `build()`, the runtime drives OAuth.
397
127
 
398
- For bidirectional sync where actions should be attributed to the acting user:
399
-
400
- ```typescript
401
- await this.tools.integrations.actAs(
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
- ### Cross-Connector Auth Sharing (Google Connectors)
133
+ `AuthProvider` values: `Google`, `Microsoft`, `Notion`, `Slack`, `Atlassian`, `Linear`, `Monday`, `GitHub`, `Asana`, `HubSpot`.
417
134
 
418
- When building a Google connector that should also sync contacts, merge scopes:
135
+ ### Per-user auth for write-backs
419
136
 
420
137
  ```typescript
421
- import GoogleContacts from "@plotday/connector-google-contacts";
138
+ await this.tools.integrations.actAs(MyConnector.PROVIDER, actorId, activityId, this.performWriteBack, ...args);
422
139
 
423
- build(build: ConnectorBuilder) {
424
- return {
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
- ## Architecture: Connectors Save Directly
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
- ## Critical: Callback Serialization Pattern
147
+ Merge scopes with `Integrations.MergeScopes(MyGoogleConnector.SCOPES, GoogleContacts.SCOPES)` and add `googleContacts: build(GoogleContacts)` to your `build()` return.
453
148
 
454
- **The #1 mistake when building connectors is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries.
149
+ ## Architecture
455
150
 
456
- ### WRONG - Passing Function as Callback Argument
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
- ```typescript
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
- **Error:** `Cannot create callback args: Found function at path "value[0]"`
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
- async startSync(resourceId: string, callback: Function, ...extraArgs: any[]): Promise<void> {
471
- // Step 1: Convert function to token and STORE it
472
- const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
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
- async syncBatch(resourceId: string): Promise<void> {
481
- // Step 3: Retrieve token from storage
482
- const callbackToken = await this.get<Callback>(`callback_${resourceId}`);
483
- if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
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
- // Step 4: Fetch data and execute callback with result
486
- const result = await this.fetchItems(resourceId);
487
- for (const item of result.items) {
488
- await this.tools.callbacks.run(callbackToken, item);
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
- ### What's Serializable
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 Backward Compatibility
177
+ ## Callback backward compatibility
504
178
 
505
- **All callbacks automatically upgrade to new connector versions on deployment.** You MUST maintain backward compatibility.
179
+ Deployed callbacks auto-upgrade to new connector versions. Signatures must stay compatible:
506
180
 
507
- - Don't change function signatures (remove/reorder params, change types)
508
- - Do add optional parameters at the end
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 - Original
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; // Safe default for old callbacks
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
- ## Storage Key Conventions
193
+ For breaking changes, do migration in `preUpgrade()` (e.g. clear stale locks).
537
194
 
538
- All connectors use consistent key prefixes:
195
+ ## Storage key conventions
539
196
 
540
- | Key Pattern | Purpose |
541
- |------------|---------|
542
- | `item_callback_<id>` | Serialized callback to parent's `onItem` |
543
- | `disable_callback_<id>` | Serialized callback to parent's `onChannelDisabled` |
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 ID |
203
+ | `webhook_id_<id>` | External webhook registration id |
547
204
  | `webhook_secret_<id>` | Webhook signing secret |
548
- | `watch_renewal_task_<id>` | Scheduled task token for webhook renewal |
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
- ### Source identifier uniqueness (CRITICAL)
207
+ ## `source` idempotency + cross-user dedup (CRITICAL)
560
208
 
561
- `source` is the **cross-user deduplication key** for the Plot runtime. Two instances of the same connector that emit the same `source` string will **converge on a single shared thread** across users that's how two users on the same Gmail message see one shared thread rather than two parallel ones.
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
- This means your `source` must be globally unique for the logical external item not merely unique within a single user's account. Before committing a source pattern, ask yourself: *"Could two different users' connector instances emit this exact string for different external items?"* If yes, you must include a qualifier (workspace, tenant, mailbox, project, …).
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 patterns (globally unique external ids):
213
+ Safe (globally unique ids):
566
214
  ```
567
- linear:issue:<uuid> — Linear issue UUIDs are globally unique
568
- github:<owner>/<repo>/issue:<number> — Scoped by owner+repo
569
- google-chat:<spaceId>:thread:<threadKey> — Space id globally unique
570
- ms-teams:channel:<channelId>:message:<id> — Teams channel id globally unique
571
- ms-teams:dm:<chatId> — Chat ids globally unique
572
- https://mail.google.com/mail/u/0/#inbox/<threadId> — Gmail thread id globally unique
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
- Patterns that need disambiguation:
223
+ Need disambiguation (scoped ids):
576
224
  ```
577
- attio:<workspaceId>:<type>:<recordId> — Attio record ids are workspace-scoped
578
- posthog:<projectId>:person:<distinctId> — distinct_id is project-scoped (often just an email)
579
- outlook-calendar:<mailboxId>:<eventId> — Graph event ids are mailbox-local
580
- fellow:<tenantId>:note:<id> — Fellow ids are tenant-scoped
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
- **If you're adding a new connector, pick a source format that encodes the tenant/workspace/mailbox upfront.** Retrofits are possible but require a backfill migration.
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
- ### Attestation-based visibility for shared threads
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
- When your connector populates `thread.contacts` from an external item's recipients, listing someone there does NOT automatically admit them to the thread. The runtime requires that each user's own connector instance independently sync the item (proof that it's in their authenticated account) before they gain a `thread_priority` row. Users whose own sync arrives before any other user has attested them land in `thread.pending_contacts` and are promoted to `thread.contacts` on the next attester's sync.
235
+ ### Attestation-based visibility
590
236
 
591
- You don't need to do anything special for this just continue to populate `contacts` with every recipient you see. The runtime's `upsert_thread` enforces attestation; connectors can treat visibility as a server-side concern.
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 Key Conventions
239
+ ## Note key conventions
594
240
 
595
- `note.key` enables note-level upserts within an activity:
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" Main content / description note
599
- "summary" Document summary
600
- "metadata" Status/priority/assignee metadata
601
- "cancellation" Cancelled event note
602
- "comment-<externalCommentId>" — Individual comment
603
- "reply-<commentId>-<replyId>" — Reply to a comment
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
- ### When APIs provide both HTML and plain text
252
+ ## HTML content
633
253
 
634
- Prefer HTML the server-side `toMarkdown()` conversion (via Cloudflare AI) produces cleaner output with proper links, formatting, and character encoding. Only use plain text if no HTML is available.
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
- function extractBody(part: MessagePart): { content: string; contentType: "text" | "html" } {
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
- ### Previews
260
+ When both are available, prefer HTML. Use `"text"` only if no HTML is available.
650
261
 
651
- For `preview` fields on threads/links, use a plain-text source (like Gmail's `snippet` or a truncated title) never raw HTML. Previews are displayed directly and are not processed by the server.
262
+ Previews (`preview` fields) always use plain text `snippet` or truncated title, never HTML.
652
263
 
653
- ### ContentType values
264
+ `contentType`: `"text"` (auto-links URLs), `"markdown"` (default), `"html"` (server-converted).
654
265
 
655
- | Value | Meaning |
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
- ## Sync Metadata Injection
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
- // In the twist:
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. Incremental Sync (REQUIRED)
274
+ ## Initial vs incremental sync (REQUIRED)
683
275
 
684
- **Every connector MUST track whether it is performing an initial sync (first import) or an incremental sync (ongoing updates).** Omitting this causes notification spam from bulk historical imports.
276
+ Missing this causes notification spam from bulk historical imports.
685
277
 
686
- | Field | Initial Sync | Incremental Sync | Reason |
687
- |-------|-------------|------------------|--------|
688
- | `unread` | `false` | *omit* | Initial: mark all read. Incremental: auto-mark read for author only |
689
- | `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |
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
- ### How to propagate the flag
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 B: Pass as callback argument** (used by connectors like Gmail that don't store `initialSync` in state)
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
- Pass `initialSync` as an explicit argument through `this.callback()`:
294
+ Entry points: `onChannelEnabled`/`startSync` `true`; webhook/incremental `false`; next batch → propagate current value.
710
295
 
711
- ```typescript
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 Guard (REQUIRED)
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
- ### Webhook Verification
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 filtering |
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 Renewal (Calendar/Drive)
763
-
764
- For providers that expire watches, schedule proactive renewal:
315
+ ### Watch renewal (Calendar/Drive)
765
316
 
766
317
  ```typescript
767
- private async scheduleWatchRenewal(resourceId: string): Promise<void> {
768
- const expiresAt = /* watch expiry from provider */;
769
- const renewalTime = new Date(expiresAt.getTime() - 24 * 60 * 60 * 1000); // 24h before
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 Sync
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
- ```typescript
799
- async addIssueComment(meta: ActivityMeta, body: string, noteId?: string): Promise<string | void> {
800
- const externalId = meta.externalId as string;
801
- if (!externalId) throw new Error("External ID not found");
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
- const client = await this.getClient(meta.resourceId as string);
804
- const comment = await client.createComment(externalId, { body });
805
- if (comment?.id) return `comment-${comment.id}`;
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
- ### Loop Prevention
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
- The parent twist prevents infinite loops by checking note authorship:
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
- // In the twist (not the connector):
815
- async onNoteCreated(note: Note): Promise<void> {
816
- if (note.author.type === ActorType.Twist) return; // Prevent loops
817
- // ... sync note to external service
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
- ### Default Mention on Replies
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
- export class MyConnector extends Connector<MyConnector> {
827
- static readonly handleReplies = true; // Replies to synced threads mention this connector by default
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
- Without this, the connector cannot be @-mentioned at all. Connectors that don't process replies (e.g., read-only calendar sync) should NOT set this flag.
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
- ```typescript
896
- import type { NewContact } from "@plotday/twister/plot";
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
- const authorContact: NewContact | undefined = creator?.email ? {
899
- email: creator.email,
900
- name: creator.name,
901
- avatar: creator.avatarUrl ?? undefined,
902
- } : undefined;
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
- Contacts are created implicitly when saving threads/links via `integrations.saveLink()` — no explicit `addContacts()` call or `ContactAccess.Write` permission is needed.
449
+ ## Buffer declaration
916
450
 
917
- ## Buffer Declaration
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
- data: string | ArrayBuffer | Uint8Array,
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
- ## Building and Testing
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
- # Install dependencies (from repo root)
940
- pnpm install
941
- ```
942
-
943
- After creating a new connector, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern.
944
-
945
- ## Connector Development Checklist
946
-
947
- - [ ] Extend `Connector<YourConnector>`
948
- - [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES`
949
- - [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions`
950
- - [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks
951
- - [ ] Set `static readonly handleReplies = true` if the connector supports bidirectional sync
952
- - [ ] Implement `getChannels()`, `onChannelEnabled()`, `onChannelDisabled()` **use `runTask()` (not `run()`) in `onChannelEnabled` to avoid blocking the API response**
953
- - [ ] Convert parent callbacks to tokens with `createFromParent()` — **never pass functions to `this.callback()`**
954
- - [ ] Store callback tokens with `this.set()`, retrieve with `this.get<Callback>()`
955
- - [ ] Pass only serializable values (no functions, no undefined) to `this.callback()`
956
- - [ ] Implement batch sync with `this.tools.tasks.runTask()` for fresh request limits
957
- - [ ] Add localhost guard in webhook setup
958
- - [ ] Verify webhook signatures
959
- - [ ] Use canonical `source` URLs for activity upserts (immutable IDs)
960
- - [ ] Use `note.key` for note-level upserts
961
- - [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally**
962
- - [ ] Inject `syncProvider` and `channelId` into `activity.meta`
963
- - [ ] Set `created` on notes using the external system's timestamp (not sync time)
964
- - [ ] Handle `initialSync` flag in **every sync entry point**: `onChannelEnabled`/`startSync` set `true`, webhooks/incremental set `false`, and the flag is propagated through all batch callbacks to where activities are created. Set `unread: false` and `archived: false` for initial, omit both for incremental
965
- - [ ] Create contacts for authors/assignees with `NewContact`
966
- - [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()`
967
- - [ ] **If the connector should let users create new items from Plot**: mark one status per opted-in `LinkTypeConfig` with `createDefault: true` and implement `onCreateLink(draft)`. Return a `NewLinkWithNotes` — never call `integrations.saveLink()` from `onCreateLink`
968
- - [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export
969
- - [ ] Verify the connector builds: `pnpm build`
970
-
971
- ## Common Pitfalls
972
-
973
- 1. **❌ Passing functions to `this.callback()`** Convert to tokens first with `createFromParent()`
974
- 2. **❌ Storing functions with `this.set()`** Convert to tokens first
975
- 3. **❌ Not validating callback token exists** Always check before `callbacks.run()`
976
- 4. **❌ Forgetting sync metadata** Always inject `syncProvider` and `channelId` into `activity.meta`
977
- 5. **❌ Not propagating `initialSync` through the full sync pipeline** — The flag must flow from the entry point (`onChannelEnabled`/`startSync` `true`, webhook `false`) through every batch callback to where activities are created. Missing this causes notification spam from bulk historical imports
978
- 6. **❌ Using mutable IDs in `source`** Use immutable IDs (Jira issue ID, not issue key)
979
- 7. **❌ Not breaking loops into batches** Each execution has ~1000 request limit
980
- 8. **❌ Missing localhost guard** Webhook registration fails silently on localhost
981
- 9. **❌ Calling `plot.createThread()` from a connector** Connectors save data directly via `integrations.saveLink()`
982
- 10. **❌ Breaking callback signatures** Old callbacks auto-upgrade; add optional params at end only
983
- 11. **❌ Passing `undefined` in serializable values** Use `null` instead
984
- 12. **❌ Forgetting to clean up on disable** Delete callbacks, webhooks, and stored state
985
- 13. **❌ Two-way sync without metadata correlation** Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6)
986
- 14. **❌ Stripping HTML tags locally** Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links
987
- 15. **❌ Using placeholder titles in comment/update webhooks** `title` always overwrites on upsert. Always use the real entity title (fetch from API if not in the webhook payload). Never use IDs or keys as placeholder titles
988
- 16. **❌ Not setting `created` on notes from external data** — Always pass the external system's timestamp (e.g., `internalDate` from Gmail, `created_at` from an API) as the note's `created` field. Omitting it defaults to sync time, making all notes appear to have been created "just now"
989
- 17. **❌ Using `this.run()` in `onChannelEnabled` to start sync** — `onChannelEnabled` runs synchronously inside the API request handler. Using `this.run()` (which executes inline) blocks the HTTP response until the entire sync completes, causing client timeouts. Always use `this.runTask()` to queue the initial sync as a separate execution so `onChannelEnabled` returns quickly
990
- 18. **❌ Calling `integrations.saveLink()` from `onCreateLink`** — The platform wires the returned link to the user's originating thread. Calling `saveLink` yourself creates a duplicate thread. Just return the `NewLinkWithNotes`
991
- 19. **❌ Forgetting to mark a status with `createDefault: true`** — Without it, Plot has no idea the link type opts in to Plot-initiated creation, so the "Create new X" entry never appears in the Add link modal. Declaring the marker is what opts a link type in, not implementing `onCreateLink` alone
992
-
993
- ## Study These Examples
994
-
995
- | Connector | Category | Key Patterns |
996
- |-----------|----------|-------------|
997
- | `linear/` | ProjectConnector | Clean reference implementation, webhook handling, bidirectional sync |
998
- | `google-calendar/` | CalendarConnector | Recurring events, RSVP write-back, watch renewal, cross-connector auth sharing |
999
- | `slack/` | MessagingConnector | Team-sharded webhooks, thread model, Slack-specific auth |
1000
- | `gmail/` | MessagingConnector | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern |
1001
- | `google-drive/` | DocumentConnector | Document comments, reply threading, file watching |
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 disableorphan 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()` |