@plotday/twister 0.36.0 → 0.38.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 (243) hide show
  1. package/README.md +4 -2
  2. package/bin/commands/create.js +37 -3
  3. package/bin/commands/create.js.map +1 -1
  4. package/bin/commands/deploy.js +4 -0
  5. package/bin/commands/deploy.js.map +1 -1
  6. package/bin/index.js +1 -0
  7. package/bin/index.js.map +1 -1
  8. package/bin/templates/AGENTS.template.md +101 -189
  9. package/bin/templates/README.template.md +2 -23
  10. package/cli/templates/AGENTS.template.md +101 -189
  11. package/cli/templates/README.template.md +2 -23
  12. package/dist/docs/assets/hierarchy.js +1 -1
  13. package/dist/docs/assets/navigation.js +1 -1
  14. package/dist/docs/assets/search.js +1 -1
  15. package/dist/docs/classes/index.Options.html +1 -1
  16. package/dist/docs/classes/index.Source.html +200 -0
  17. package/dist/docs/classes/tool.ITool.html +1 -1
  18. package/dist/docs/classes/tool.Tool.html +21 -21
  19. package/dist/docs/classes/tools_ai.AI.html +1 -1
  20. package/dist/docs/classes/tools_callbacks.Callbacks.html +2 -2
  21. package/dist/docs/classes/tools_integrations.Integrations.html +45 -16
  22. package/dist/docs/classes/tools_network.Network.html +1 -1
  23. package/dist/docs/classes/tools_plot.Plot.html +93 -60
  24. package/dist/docs/classes/tools_store.Store.html +1 -1
  25. package/dist/docs/classes/tools_tasks.Tasks.html +1 -1
  26. package/dist/docs/classes/tools_twists.Twists.html +1 -1
  27. package/dist/docs/classes/twist.Twist.html +42 -10
  28. package/dist/docs/documents/Building_Sources.html +137 -0
  29. package/dist/docs/documents/Built-in_Tools.html +11 -2
  30. package/dist/docs/documents/Core_Concepts.html +5 -10
  31. package/dist/docs/documents/Getting_Started.html +1 -1
  32. package/dist/docs/enums/{plot.ActivityLinkType.html → plot.ActionType.html} +10 -8
  33. package/dist/docs/enums/plot.ActorType.html +7 -7
  34. package/dist/docs/enums/plot.ConferencingProvider.html +6 -6
  35. package/dist/docs/enums/plot.ThemeColor.html +9 -9
  36. package/dist/docs/enums/tag.Tag.html +3 -10
  37. package/dist/docs/enums/tools_integrations.AuthProvider.html +11 -11
  38. package/dist/docs/enums/tools_plot.ContactAccess.html +3 -3
  39. package/dist/docs/enums/tools_plot.PriorityAccess.html +3 -3
  40. package/dist/docs/enums/{tools_plot.ActivityAccess.html → tools_plot.ThreadAccess.html} +6 -6
  41. package/dist/docs/hierarchy.html +1 -1
  42. package/dist/docs/index.html +5 -6
  43. package/dist/docs/media/AGENTS.md +910 -0
  44. package/dist/docs/media/MULTI_USER_AUTH.md +111 -0
  45. package/dist/docs/media/SYNC_STRATEGIES.md +7 -7
  46. package/dist/docs/modules/index.html +1 -1
  47. package/dist/docs/modules/plot.html +1 -1
  48. package/dist/docs/modules/tool.html +1 -1
  49. package/dist/docs/modules/tools_integrations.html +1 -1
  50. package/dist/docs/modules/tools_plot.html +1 -1
  51. package/dist/docs/modules.html +1 -1
  52. package/dist/docs/types/index.NewSchedule.html +33 -0
  53. package/dist/docs/types/index.NewScheduleContact.html +5 -0
  54. package/dist/docs/types/index.NewScheduleOccurrence.html +6 -0
  55. package/dist/docs/types/index.Schedule.html +37 -0
  56. package/dist/docs/types/index.ScheduleContact.html +5 -0
  57. package/dist/docs/types/index.ScheduleContactRole.html +1 -0
  58. package/dist/docs/types/index.ScheduleContactStatus.html +1 -0
  59. package/dist/docs/types/index.ScheduleOccurrence.html +17 -0
  60. package/dist/docs/types/index.ScheduleOccurrenceUpdate.html +2 -0
  61. package/dist/docs/types/plot.Action.html +28 -0
  62. package/dist/docs/types/plot.Actor.html +6 -6
  63. package/dist/docs/types/plot.ActorId.html +1 -1
  64. package/dist/docs/types/plot.ContentType.html +1 -1
  65. package/dist/docs/types/plot.Link.html +36 -0
  66. package/dist/docs/types/plot.NewActor.html +1 -1
  67. package/dist/docs/types/plot.NewContact.html +5 -5
  68. package/dist/docs/types/plot.NewLink.html +26 -0
  69. package/dist/docs/types/plot.NewLinkWithNotes.html +7 -0
  70. package/dist/docs/types/plot.NewNote.html +10 -10
  71. package/dist/docs/types/plot.NewPriority.html +1 -1
  72. package/dist/docs/types/plot.NewTags.html +1 -1
  73. package/dist/docs/types/plot.NewThread.html +27 -0
  74. package/dist/docs/types/plot.NewThreadWithNotes.html +1 -0
  75. package/dist/docs/types/plot.Note.html +9 -8
  76. package/dist/docs/types/plot.NoteUpdate.html +2 -2
  77. package/dist/docs/types/plot.PickPriorityConfig.html +8 -10
  78. package/dist/docs/types/plot.Priority.html +6 -6
  79. package/dist/docs/types/plot.PriorityUpdate.html +1 -1
  80. package/dist/docs/types/plot.Tags.html +1 -1
  81. package/dist/docs/types/plot.Thread.html +1 -0
  82. package/dist/docs/types/plot.ThreadCommon.html +17 -0
  83. package/dist/docs/types/plot.ThreadFilter.html +2 -0
  84. package/dist/docs/types/plot.ThreadMeta.html +11 -0
  85. package/dist/docs/types/plot.ThreadUpdate.html +3 -0
  86. package/dist/docs/types/plot.ThreadWithNotes.html +1 -0
  87. package/dist/docs/types/tools_integrations.ArchiveLinkFilter.html +11 -0
  88. package/dist/docs/types/tools_integrations.AuthToken.html +4 -4
  89. package/dist/docs/types/tools_integrations.Authorization.html +4 -4
  90. package/dist/docs/types/tools_integrations.Channel.html +11 -0
  91. package/dist/docs/types/tools_integrations.LinkTypeConfig.html +17 -0
  92. package/dist/docs/types/tools_plot.LinkFilter.html +10 -0
  93. package/dist/docs/types/tools_plot.LinkSearchResult.html +1 -0
  94. package/dist/docs/types/tools_plot.NoteIntentHandler.html +6 -6
  95. package/dist/docs/types/tools_plot.NoteSearchResult.html +1 -0
  96. package/dist/docs/types/tools_plot.SearchOptions.html +7 -0
  97. package/dist/docs/types/tools_plot.SearchResult.html +1 -0
  98. package/dist/docs/types/tools_twists.TwistPermissions.html +1 -1
  99. package/dist/docs/variables/tools_plot.SEARCH_DEFAULT_LIMIT.html +2 -0
  100. package/dist/docs/variables/tools_plot.SEARCH_MAX_LIMIT.html +2 -0
  101. package/dist/index.d.ts +2 -0
  102. package/dist/index.d.ts.map +1 -1
  103. package/dist/index.js +2 -0
  104. package/dist/index.js.map +1 -1
  105. package/dist/llm-docs/index.d.ts.map +1 -1
  106. package/dist/llm-docs/index.js +4 -8
  107. package/dist/llm-docs/index.js.map +1 -1
  108. package/dist/llm-docs/plot.d.ts +1 -1
  109. package/dist/llm-docs/plot.d.ts.map +1 -1
  110. package/dist/llm-docs/plot.js +1 -1
  111. package/dist/llm-docs/plot.js.map +1 -1
  112. package/dist/llm-docs/schedule.d.ts +9 -0
  113. package/dist/llm-docs/schedule.d.ts.map +1 -0
  114. package/dist/llm-docs/schedule.js +8 -0
  115. package/dist/llm-docs/schedule.js.map +1 -0
  116. package/dist/llm-docs/source.d.ts +9 -0
  117. package/dist/llm-docs/source.d.ts.map +1 -0
  118. package/dist/llm-docs/source.js +8 -0
  119. package/dist/llm-docs/source.js.map +1 -0
  120. package/dist/llm-docs/tag.d.ts +1 -1
  121. package/dist/llm-docs/tag.d.ts.map +1 -1
  122. package/dist/llm-docs/tag.js +1 -1
  123. package/dist/llm-docs/tag.js.map +1 -1
  124. package/dist/llm-docs/tool.d.ts +1 -1
  125. package/dist/llm-docs/tool.d.ts.map +1 -1
  126. package/dist/llm-docs/tool.js +1 -1
  127. package/dist/llm-docs/tool.js.map +1 -1
  128. package/dist/llm-docs/tools/callbacks.d.ts +1 -1
  129. package/dist/llm-docs/tools/callbacks.d.ts.map +1 -1
  130. package/dist/llm-docs/tools/callbacks.js +1 -1
  131. package/dist/llm-docs/tools/callbacks.js.map +1 -1
  132. package/dist/llm-docs/tools/integrations.d.ts +1 -1
  133. package/dist/llm-docs/tools/integrations.d.ts.map +1 -1
  134. package/dist/llm-docs/tools/integrations.js +1 -1
  135. package/dist/llm-docs/tools/integrations.js.map +1 -1
  136. package/dist/llm-docs/tools/plot.d.ts +1 -1
  137. package/dist/llm-docs/tools/plot.d.ts.map +1 -1
  138. package/dist/llm-docs/tools/plot.js +1 -1
  139. package/dist/llm-docs/tools/plot.js.map +1 -1
  140. package/dist/llm-docs/tools/twists.d.ts +1 -1
  141. package/dist/llm-docs/tools/twists.d.ts.map +1 -1
  142. package/dist/llm-docs/tools/twists.js +1 -1
  143. package/dist/llm-docs/tools/twists.js.map +1 -1
  144. package/dist/llm-docs/twist-guide-template.d.ts +1 -1
  145. package/dist/llm-docs/twist-guide-template.d.ts.map +1 -1
  146. package/dist/llm-docs/twist-guide-template.js +1 -1
  147. package/dist/llm-docs/twist-guide-template.js.map +1 -1
  148. package/dist/llm-docs/twist.d.ts +1 -1
  149. package/dist/llm-docs/twist.d.ts.map +1 -1
  150. package/dist/llm-docs/twist.js +1 -1
  151. package/dist/llm-docs/twist.js.map +1 -1
  152. package/dist/plot.d.ts +264 -589
  153. package/dist/plot.d.ts.map +1 -1
  154. package/dist/plot.js +18 -49
  155. package/dist/plot.js.map +1 -1
  156. package/dist/schedule.d.ts +172 -0
  157. package/dist/schedule.d.ts.map +1 -0
  158. package/dist/schedule.js +2 -0
  159. package/dist/schedule.js.map +1 -0
  160. package/dist/source.d.ts +158 -0
  161. package/dist/source.d.ts.map +1 -0
  162. package/dist/source.js +144 -0
  163. package/dist/source.js.map +1 -0
  164. package/dist/tag.d.ts +3 -10
  165. package/dist/tag.d.ts.map +1 -1
  166. package/dist/tag.js +2 -11
  167. package/dist/tag.js.map +1 -1
  168. package/dist/tool.d.ts +1 -15
  169. package/dist/tool.d.ts.map +1 -1
  170. package/dist/tool.js.map +1 -1
  171. package/dist/tools/callbacks.d.ts +1 -1
  172. package/dist/tools/callbacks.js +1 -1
  173. package/dist/tools/integrations.d.ts +119 -52
  174. package/dist/tools/integrations.d.ts.map +1 -1
  175. package/dist/tools/integrations.js +23 -22
  176. package/dist/tools/integrations.js.map +1 -1
  177. package/dist/tools/plot.d.ts +182 -117
  178. package/dist/tools/plot.d.ts.map +1 -1
  179. package/dist/tools/plot.js +23 -21
  180. package/dist/tools/plot.js.map +1 -1
  181. package/dist/tools/twists.d.ts +1 -1
  182. package/dist/twist-guide.d.ts +1 -1
  183. package/dist/twist-guide.d.ts.map +1 -1
  184. package/dist/twist.d.ts +66 -11
  185. package/dist/twist.d.ts.map +1 -1
  186. package/dist/twist.js +79 -10
  187. package/dist/twist.js.map +1 -1
  188. package/package.json +11 -41
  189. package/dist/common/calendar.d.ts +0 -135
  190. package/dist/common/calendar.d.ts.map +0 -1
  191. package/dist/common/calendar.js +0 -2
  192. package/dist/common/calendar.js.map +0 -1
  193. package/dist/common/documents.d.ts +0 -122
  194. package/dist/common/documents.d.ts.map +0 -1
  195. package/dist/common/documents.js +0 -2
  196. package/dist/common/documents.js.map +0 -1
  197. package/dist/common/messaging.d.ts +0 -84
  198. package/dist/common/messaging.d.ts.map +0 -1
  199. package/dist/common/messaging.js +0 -2
  200. package/dist/common/messaging.js.map +0 -1
  201. package/dist/common/projects.d.ts +0 -112
  202. package/dist/common/projects.d.ts.map +0 -1
  203. package/dist/common/projects.js +0 -2
  204. package/dist/common/projects.js.map +0 -1
  205. package/dist/docs/documents/Building_Custom_Tools.html +0 -215
  206. package/dist/docs/enums/plot.ActivityKind.html +0 -14
  207. package/dist/docs/enums/plot.ActivityType.html +0 -10
  208. package/dist/docs/modules/common_calendar.html +0 -1
  209. package/dist/docs/types/common_calendar.Calendar.html +0 -13
  210. package/dist/docs/types/common_calendar.CalendarTool.html +0 -81
  211. package/dist/docs/types/common_calendar.SyncOptions.html +0 -24
  212. package/dist/docs/types/plot.Activity.html +0 -2
  213. package/dist/docs/types/plot.ActivityCommon.html +0 -20
  214. package/dist/docs/types/plot.ActivityFilter.html +0 -3
  215. package/dist/docs/types/plot.ActivityLink.html +0 -26
  216. package/dist/docs/types/plot.ActivityMeta.html +0 -11
  217. package/dist/docs/types/plot.ActivityOccurrence.html +0 -22
  218. package/dist/docs/types/plot.ActivityOccurrenceUpdate.html +0 -3
  219. package/dist/docs/types/plot.ActivityUpdate.html +0 -3
  220. package/dist/docs/types/plot.ActivityWithNotes.html +0 -1
  221. package/dist/docs/types/plot.NewActivity.html +0 -81
  222. package/dist/docs/types/plot.NewActivityOccurrence.html +0 -24
  223. package/dist/docs/types/plot.NewActivityWithNotes.html +0 -1
  224. package/dist/docs/types/tool.SyncToolOptions.html +0 -9
  225. package/dist/docs/types/tools_integrations.IntegrationOptions.html +0 -4
  226. package/dist/docs/types/tools_integrations.IntegrationProviderConfig.html +0 -13
  227. package/dist/docs/types/tools_integrations.Syncable.html +0 -9
  228. package/dist/llm-docs/common/calendar.d.ts +0 -9
  229. package/dist/llm-docs/common/calendar.d.ts.map +0 -1
  230. package/dist/llm-docs/common/calendar.js +0 -8
  231. package/dist/llm-docs/common/calendar.js.map +0 -1
  232. package/dist/llm-docs/common/documents.d.ts +0 -9
  233. package/dist/llm-docs/common/documents.d.ts.map +0 -1
  234. package/dist/llm-docs/common/documents.js +0 -8
  235. package/dist/llm-docs/common/documents.js.map +0 -1
  236. package/dist/llm-docs/common/messaging.d.ts +0 -9
  237. package/dist/llm-docs/common/messaging.d.ts.map +0 -1
  238. package/dist/llm-docs/common/messaging.js +0 -8
  239. package/dist/llm-docs/common/messaging.js.map +0 -1
  240. package/dist/llm-docs/common/projects.d.ts +0 -9
  241. package/dist/llm-docs/common/projects.d.ts.map +0 -1
  242. package/dist/llm-docs/common/projects.js +0 -8
  243. package/dist/llm-docs/common/projects.js.map +0 -1
@@ -0,0 +1,910 @@
1
+ # Source Development Guide
2
+
3
+ This guide covers everything needed to build a Plot source correctly.
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 Source Scaffold
10
+
11
+ Every source follows this structure:
12
+
13
+ ```
14
+ sources/<name>/
15
+ src/
16
+ index.ts # Re-exports: export { default, ClassName } from "./class-file"
17
+ <class-name>.ts # Main Source class
18
+ <api-name>.ts # (optional) Separate API client + transform functions
19
+ package.json
20
+ tsconfig.json
21
+ README.md
22
+ LICENSE
23
+ ```
24
+
25
+ ### package.json
26
+
27
+ ```json
28
+ {
29
+ "name": "@plotday/source-<name>",
30
+ "displayName": "Human Name",
31
+ "description": "One-line purpose statement",
32
+ "author": "Plot <team@plot.day> (https://plot.day)",
33
+ "license": "MIT",
34
+ "version": "0.1.0",
35
+ "type": "module",
36
+ "main": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "exports": {
39
+ ".": {
40
+ "@plotday/source": "./src/index.ts",
41
+ "types": "./dist/index.d.ts",
42
+ "default": "./dist/index.js"
43
+ }
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE"],
46
+ "scripts": {
47
+ "build": "tsc",
48
+ "clean": "rm -rf dist"
49
+ },
50
+ "dependencies": {
51
+ "@plotday/twister": "workspace:^"
52
+ },
53
+ "devDependencies": {
54
+ "typescript": "^5.9.3"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "https://github.com/plotday/plot.git",
59
+ "directory": "sources/<name>"
60
+ },
61
+ "homepage": "https://plot.day",
62
+ "keywords": ["plot", "source", "<name>"],
63
+ "publishConfig": { "access": "public" }
64
+ }
65
+ ```
66
+
67
+ **Notes:**
68
+ - `"@plotday/source"` 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/source-google-contacts` as `"workspace:^"` if your source syncs contacts (Google sources only)
71
+
72
+ ### tsconfig.json
73
+
74
+ ```json
75
+ {
76
+ "$schema": "https://json.schemastore.org/tsconfig",
77
+ "extends": "@plotday/twister/tsconfig.base.json",
78
+ "compilerOptions": {
79
+ "outDir": "./dist"
80
+ },
81
+ "include": ["src/**/*.ts"]
82
+ }
83
+ ```
84
+
85
+ ### src/index.ts
86
+
87
+ ```typescript
88
+ export { default, SourceName } from "./source-name";
89
+ ```
90
+
91
+ ## Source Class Template
92
+
93
+ ```typescript
94
+ import {
95
+ ActivityType,
96
+ LinkType,
97
+ type NewActivity,
98
+ type NewActivityWithNotes,
99
+ type NewNote,
100
+ type SyncToolOptions,
101
+ Source,
102
+ type SourceBuilder,
103
+ } from "@plotday/twister";
104
+ import type { NewContact } from "@plotday/twister/plot";
105
+ import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks";
106
+ import {
107
+ AuthProvider,
108
+ type AuthToken,
109
+ type Authorization,
110
+ Integrations,
111
+ type Channel,
112
+ } from "@plotday/twister/tools/integrations";
113
+ import { Network, type WebhookRequest } from "@plotday/twister/tools/network";
114
+ import { ContactAccess, Plot } from "@plotday/twister/tools/plot";
115
+ import { Tasks } from "@plotday/twister/tools/tasks";
116
+
117
+ type SyncState = {
118
+ cursor: string | null;
119
+ batchNumber: number;
120
+ itemsProcessed: number;
121
+ initialSync: boolean;
122
+ };
123
+
124
+ export class MySource extends Source<MySource> {
125
+ // 1. Static constants
126
+ static readonly PROVIDER = AuthProvider.Linear; // Use appropriate provider
127
+ static readonly SCOPES = ["read", "write"];
128
+ static readonly Options: SyncToolOptions;
129
+ declare readonly Options: SyncToolOptions;
130
+
131
+ // 2. Declare dependencies
132
+ build(build: SourceBuilder) {
133
+ return {
134
+ integrations: build(Integrations, {
135
+ providers: [{
136
+ provider: MySource.PROVIDER,
137
+ scopes: MySource.SCOPES,
138
+ getChannels: this.getChannels,
139
+ onChannelEnabled: this.onChannelEnabled,
140
+ onChannelDisabled: this.onChannelDisabled,
141
+ }],
142
+ }),
143
+ network: build(Network, { urls: ["https://api.example.com/*"] }),
144
+ callbacks: build(Callbacks),
145
+ tasks: build(Tasks),
146
+ plot: build(Plot, { contact: { access: ContactAccess.Write } }),
147
+ };
148
+ }
149
+
150
+ // 3. Create API client using channel-based auth
151
+ private async getClient(channelId: string): Promise<any> {
152
+ const token = await this.tools.integrations.get(MySource.PROVIDER, channelId);
153
+ if (!token) throw new Error("No authentication token available");
154
+ return new SomeApiClient({ accessToken: token.token });
155
+ }
156
+
157
+ // 4. Return available resources for the user to select
158
+ async getChannels(_auth: Authorization, token: AuthToken): Promise<Channel[]> {
159
+ const client = new SomeApiClient({ accessToken: token.token });
160
+ const resources = await client.listResources();
161
+ return resources.map(r => ({ id: r.id, title: r.name }));
162
+ }
163
+
164
+ // 5. Called when user enables a resource
165
+ async onChannelEnabled(channel: Channel): Promise<void> {
166
+ await this.set(`sync_enabled_${channel.id}`, true);
167
+
168
+ // Store parent callback tokens
169
+ const itemCallbackToken = await this.tools.callbacks.createFromParent(
170
+ this.options.onItem
171
+ );
172
+ await this.set(`item_callback_${channel.id}`, itemCallbackToken);
173
+
174
+ if (this.options.onChannelDisabled) {
175
+ const disableCallbackToken = await this.tools.callbacks.createFromParent(
176
+ this.options.onChannelDisabled,
177
+ { meta: { syncProvider: "myprovider", channelId: channel.id } }
178
+ );
179
+ await this.set(`disable_callback_${channel.id}`, disableCallbackToken);
180
+ }
181
+
182
+ // Setup webhook and start initial sync
183
+ await this.setupWebhook(channel.id);
184
+ await this.startBatchSync(channel.id);
185
+ }
186
+
187
+ // 6. Called when user disables a resource
188
+ async onChannelDisabled(channel: Channel): Promise<void> {
189
+ await this.stopSync(channel.id);
190
+
191
+ const disableCallbackToken = await this.get<Callback>(`disable_callback_${channel.id}`);
192
+ if (disableCallbackToken) {
193
+ await this.tools.callbacks.run(disableCallbackToken);
194
+ await this.tools.callbacks.delete(disableCallbackToken);
195
+ await this.clear(`disable_callback_${channel.id}`);
196
+ }
197
+
198
+ const itemCallbackToken = await this.get<Callback>(`item_callback_${channel.id}`);
199
+ if (itemCallbackToken) {
200
+ await this.tools.callbacks.delete(itemCallbackToken);
201
+ await this.clear(`item_callback_${channel.id}`);
202
+ }
203
+
204
+ await this.clear(`sync_enabled_${channel.id}`);
205
+ }
206
+
207
+ // 7. Public interface methods (from common interface)
208
+ async getProjects(projectId: string): Promise<any[]> {
209
+ const client = await this.getClient(projectId);
210
+ const projects = await client.listProjects();
211
+ return projects.map(p => ({
212
+ id: p.id,
213
+ name: p.name,
214
+ description: p.description || null,
215
+ key: p.key || null,
216
+ }));
217
+ }
218
+
219
+ async startSync<TArgs extends any[], TCallback extends Function>(
220
+ options: { projectId: string },
221
+ callback: TCallback,
222
+ ...extraArgs: TArgs
223
+ ): Promise<void> {
224
+ const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
225
+ await this.set(`item_callback_${options.projectId}`, callbackToken);
226
+ await this.setupWebhook(options.projectId);
227
+ await this.startBatchSync(options.projectId);
228
+ }
229
+
230
+ async stopSync(projectId: string): Promise<void> {
231
+ // Remove webhook
232
+ const webhookId = await this.get<string>(`webhook_id_${projectId}`);
233
+ if (webhookId) {
234
+ try {
235
+ const client = await this.getClient(projectId);
236
+ await client.deleteWebhook(webhookId);
237
+ } catch (error) {
238
+ console.warn("Failed to delete webhook:", error);
239
+ }
240
+ await this.clear(`webhook_id_${projectId}`);
241
+ }
242
+
243
+ // Cleanup callbacks
244
+ const itemCallbackToken = await this.get<Callback>(`item_callback_${projectId}`);
245
+ if (itemCallbackToken) {
246
+ await this.deleteCallback(itemCallbackToken);
247
+ await this.clear(`item_callback_${projectId}`);
248
+ }
249
+
250
+ await this.clear(`sync_state_${projectId}`);
251
+ }
252
+
253
+ // 8. Webhook setup
254
+ private async setupWebhook(resourceId: string): Promise<void> {
255
+ try {
256
+ const webhookUrl = await this.tools.network.createWebhook(
257
+ {},
258
+ this.onWebhook,
259
+ resourceId
260
+ );
261
+
262
+ // REQUIRED: Skip webhook registration in development
263
+ if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
264
+ return;
265
+ }
266
+
267
+ const client = await this.getClient(resourceId);
268
+ const webhook = await client.createWebhook({ url: webhookUrl });
269
+ if (webhook?.id) {
270
+ await this.set(`webhook_id_${resourceId}`, webhook.id);
271
+ }
272
+ } catch (error) {
273
+ console.error("Failed to set up webhook:", error);
274
+ }
275
+ }
276
+
277
+ // 9. Batch sync
278
+ private async startBatchSync(resourceId: string): Promise<void> {
279
+ await this.set(`sync_state_${resourceId}`, {
280
+ cursor: null,
281
+ batchNumber: 1,
282
+ itemsProcessed: 0,
283
+ initialSync: true,
284
+ });
285
+
286
+ const batchCallback = await this.callback(this.syncBatch, resourceId);
287
+ await this.tools.tasks.runTask(batchCallback);
288
+ }
289
+
290
+ private async syncBatch(resourceId: string): Promise<void> {
291
+ const state = await this.get<SyncState>(`sync_state_${resourceId}`);
292
+ if (!state) throw new Error(`Sync state not found for ${resourceId}`);
293
+
294
+ const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
295
+ if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
296
+
297
+ const client = await this.getClient(resourceId);
298
+ const result = await client.listItems({ cursor: state.cursor, limit: 50 });
299
+
300
+ for (const item of result.items) {
301
+ const activity = this.transformItem(item, resourceId, state.initialSync);
302
+ // Inject sync metadata for bulk operations
303
+ activity.meta = {
304
+ ...activity.meta,
305
+ syncProvider: "myprovider",
306
+ channelId: resourceId,
307
+ };
308
+ await this.tools.callbacks.run(callbackToken, activity);
309
+ }
310
+
311
+ if (result.nextCursor) {
312
+ await this.set(`sync_state_${resourceId}`, {
313
+ cursor: result.nextCursor,
314
+ batchNumber: state.batchNumber + 1,
315
+ itemsProcessed: state.itemsProcessed + result.items.length,
316
+ initialSync: state.initialSync,
317
+ });
318
+ const nextBatch = await this.callback(this.syncBatch, resourceId);
319
+ await this.tools.tasks.runTask(nextBatch);
320
+ } else {
321
+ await this.clear(`sync_state_${resourceId}`);
322
+ }
323
+ }
324
+
325
+ // 10. Data transformation
326
+ private transformItem(item: any, resourceId: string, initialSync: boolean): NewActivityWithNotes {
327
+ return {
328
+ source: `myprovider:item:${item.id}`, // Canonical source for upsert
329
+ type: ActivityType.Action,
330
+ title: item.title,
331
+ created: item.createdAt,
332
+ author: item.creator?.email ? {
333
+ email: item.creator.email,
334
+ name: item.creator.name,
335
+ } : undefined,
336
+ meta: {
337
+ externalId: item.id,
338
+ resourceId,
339
+ },
340
+ notes: [{
341
+ key: "description", // Enables note upsert
342
+ content: item.description || null,
343
+ contentType: item.descriptionHtml ? "html" as const : "text" as const,
344
+ links: item.url ? [{
345
+ type: LinkType.external,
346
+ title: "Open in Service",
347
+ url: item.url,
348
+ }] : null,
349
+ }],
350
+ ...(initialSync ? { unread: false } : {}),
351
+ ...(initialSync ? { archived: false } : {}),
352
+ };
353
+ }
354
+
355
+ // 11. Webhook handler
356
+ private async onWebhook(request: WebhookRequest, resourceId: string): Promise<void> {
357
+ // Verify webhook signature (provider-specific)
358
+ // ...
359
+
360
+ const callbackToken = await this.get<Callback>(`item_callback_${resourceId}`);
361
+ if (!callbackToken) return;
362
+
363
+ const payload = JSON.parse(request.rawBody || "{}");
364
+ const activity = this.transformItem(payload.item, resourceId, false);
365
+ activity.meta = {
366
+ ...activity.meta,
367
+ syncProvider: "myprovider",
368
+ channelId: resourceId,
369
+ };
370
+ await this.tools.callbacks.run(callbackToken, activity);
371
+ }
372
+ }
373
+
374
+ export default MySource;
375
+ ```
376
+
377
+ ## The Integrations Pattern (Auth + Channels)
378
+
379
+ **This is how ALL authentication works.** Auth is handled in the Flutter edit modal, not in code. Sources declare their provider config in `build()`.
380
+
381
+ ### How It Works
382
+
383
+ 1. Source declares providers in `build()` with `getChannels`, `onChannelEnabled`, `onChannelDisabled` callbacks
384
+ 2. User clicks "Connect" in the twist edit modal → OAuth flow happens automatically
385
+ 3. After auth, the runtime calls your `getChannels()` to list available resources
386
+ 4. User enables resources in the modal → `onChannelEnabled()` fires
387
+ 5. User disables resources → `onChannelDisabled()` fires
388
+ 6. Get tokens via `this.tools.integrations.get(PROVIDER, channelId)`
389
+
390
+ ### Available Providers
391
+
392
+ `AuthProvider` enum: `Google`, `Microsoft`, `Notion`, `Slack`, `Atlassian`, `Linear`, `Monday`, `GitHub`, `Asana`, `HubSpot`.
393
+
394
+ ### Per-User Auth for Write-Backs
395
+
396
+ For bidirectional sync where actions should be attributed to the acting user:
397
+
398
+ ```typescript
399
+ await this.tools.integrations.actAs(
400
+ MySource.PROVIDER,
401
+ actorId, // The user who performed the action
402
+ activityId, // Activity to create auth prompt in (if user hasn't connected)
403
+ this.performWriteBack,
404
+ ...extraArgs
405
+ );
406
+
407
+ async performWriteBack(token: AuthToken, ...extraArgs: any[]): Promise<void> {
408
+ // token is the acting user's token
409
+ const client = new ApiClient({ accessToken: token.token });
410
+ await client.doSomething();
411
+ }
412
+ ```
413
+
414
+ ### Cross-Source Auth Sharing (Google Sources)
415
+
416
+ When building a Google source that should also sync contacts, merge scopes:
417
+
418
+ ```typescript
419
+ import GoogleContacts from "@plotday/source-google-contacts";
420
+
421
+ build(build: SourceBuilder) {
422
+ return {
423
+ integrations: build(Integrations, {
424
+ providers: [{
425
+ provider: AuthProvider.Google,
426
+ scopes: Integrations.MergeScopes(
427
+ MyGoogleSource.SCOPES,
428
+ GoogleContacts.SCOPES
429
+ ),
430
+ getChannels: this.getChannels,
431
+ onChannelEnabled: this.onChannelEnabled,
432
+ onChannelDisabled: this.onChannelDisabled,
433
+ }],
434
+ }),
435
+ googleContacts: build(GoogleContacts),
436
+ // ...
437
+ };
438
+ }
439
+ ```
440
+
441
+ ## Architecture: Sources Save Directly
442
+
443
+ **Sources save data directly** via `integrations.saveLink()`. Sources build `NewLinkWithNotes` objects and save them, rather than passing them through a parent twist.
444
+
445
+ This means:
446
+ - Sources request `Plot` with `ContactAccess.Write` (for contacts on threads)
447
+ - Sources declare providers via `Integrations` with lifecycle callbacks
448
+ - Sources call save methods directly to persist synced data
449
+
450
+ ## Critical: Callback Serialization Pattern
451
+
452
+ **The #1 mistake when building sources is passing function references as callback arguments.** Functions cannot be serialized across worker boundaries.
453
+
454
+ ### ❌ WRONG - Passing Function as Callback Argument
455
+
456
+ ```typescript
457
+ async startSync(callback: Function, ...extraArgs: any[]): Promise<void> {
458
+ // ❌ WRONG: callback is a function — NOT SERIALIZABLE!
459
+ await this.callback(this.syncBatch, callback, ...extraArgs);
460
+ }
461
+ ```
462
+
463
+ **Error:** `Cannot create callback args: Found function at path "value[0]"`
464
+
465
+ ### ✅ CORRECT - Store Token, Pass Primitives
466
+
467
+ ```typescript
468
+ async startSync(resourceId: string, callback: Function, ...extraArgs: any[]): Promise<void> {
469
+ // Step 1: Convert function to token and STORE it
470
+ const callbackToken = await this.tools.callbacks.createFromParent(callback, ...extraArgs);
471
+ await this.set(`callback_${resourceId}`, callbackToken);
472
+
473
+ // Step 2: Pass ONLY serializable values to this.callback()
474
+ const batchCallback = await this.callback(this.syncBatch, resourceId);
475
+ await this.tools.tasks.runTask(batchCallback);
476
+ }
477
+
478
+ async syncBatch(resourceId: string): Promise<void> {
479
+ // Step 3: Retrieve token from storage
480
+ const callbackToken = await this.get<Callback>(`callback_${resourceId}`);
481
+ if (!callbackToken) throw new Error(`Callback not found for ${resourceId}`);
482
+
483
+ // Step 4: Fetch data and execute callback with result
484
+ const result = await this.fetchItems(resourceId);
485
+ for (const item of result.items) {
486
+ await this.tools.callbacks.run(callbackToken, item);
487
+ }
488
+ }
489
+ ```
490
+
491
+ ### What's Serializable
492
+
493
+ | ✅ Safe | ❌ NOT Serializable |
494
+ |---------|---------------------|
495
+ | Strings, numbers, booleans, null | Functions, `() => {}`, method refs |
496
+ | Plain objects `{ key: "value" }` | `undefined` (use `null` instead) |
497
+ | Arrays `[1, 2, 3]` | Symbols |
498
+ | Dates (serialized via SuperJSON) | RPC stubs |
499
+ | Callback tokens (branded strings) | Circular references |
500
+
501
+ ## Callback Backward Compatibility
502
+
503
+ **All callbacks automatically upgrade to new source versions on deployment.** You MUST maintain backward compatibility.
504
+
505
+ - ❌ Don't change function signatures (remove/reorder params, change types)
506
+ - ✅ Do add optional parameters at the end
507
+ - ✅ Do handle both old and new data formats with version guards
508
+
509
+ ```typescript
510
+ // v1.0 - Original
511
+ async syncBatch(batchNumber: number, resourceId: string) { ... }
512
+
513
+ // v1.1 - ✅ GOOD: Optional parameter at end
514
+ async syncBatch(batchNumber: number, resourceId: string, initialSync?: boolean) {
515
+ const isInitial = initialSync ?? true; // Safe default for old callbacks
516
+ }
517
+
518
+ // v2.0 - ❌ BAD: Completely changed signature
519
+ async syncBatch(options: SyncOptions) { ... }
520
+ ```
521
+
522
+ For breaking changes, implement migration logic in `preUpgrade()`:
523
+
524
+ ```typescript
525
+ async preUpgrade(): Promise<void> {
526
+ // Clean up stale locks from previous version
527
+ const keys = await this.list("sync_lock_");
528
+ for (const key of keys) {
529
+ await this.clear(key);
530
+ }
531
+ }
532
+ ```
533
+
534
+ ## Storage Key Conventions
535
+
536
+ All sources use consistent key prefixes:
537
+
538
+ | Key Pattern | Purpose |
539
+ |------------|---------|
540
+ | `item_callback_<id>` | Serialized callback to parent's `onItem` |
541
+ | `disable_callback_<id>` | Serialized callback to parent's `onChannelDisabled` |
542
+ | `sync_state_<id>` | Current batch pagination state |
543
+ | `sync_enabled_<id>` | Boolean tracking enabled state |
544
+ | `webhook_id_<id>` | External webhook registration ID |
545
+ | `webhook_secret_<id>` | Webhook signing secret |
546
+ | `watch_renewal_task_<id>` | Scheduled task token for webhook renewal |
547
+
548
+ ## Source URL Conventions
549
+
550
+ The `activity.source` field is the idempotency key for automatic upserts. Use a canonical format:
551
+
552
+ ```
553
+ <provider>:<entity>:<id> — Standard pattern
554
+ <provider>:<namespace>:<id> — When provider has multiple entity types
555
+ ```
556
+
557
+ Examples from existing sources:
558
+ ```
559
+ linear:issue:<issueId>
560
+ asana:task:<taskGid>
561
+ jira:<cloudId>:issue:<issueId> — Uses immutable ID, NOT mutable key like "PROJ-123"
562
+ google-calendar:<eventId>
563
+ outlook-calendar:<eventId>
564
+ google-drive:file:<fileId>
565
+ https://mail.google.com/mail/u/0/#inbox/<threadId> — Gmail uses full URL
566
+ https://slack.com/app_redirect?channel=<id>&message_ts=<ts> — Slack uses full URL
567
+ ```
568
+
569
+ **Critical:** For services with mutable identifiers (like Jira where issue keys change on project move), use the immutable ID in `source` and store the mutable key in `meta` only.
570
+
571
+ ## Note Key Conventions
572
+
573
+ `note.key` enables note-level upserts within an activity:
574
+
575
+ ```
576
+ "description" — Main content / description note
577
+ "summary" — Document summary
578
+ "metadata" — Status/priority/assignee metadata
579
+ "cancellation" — Cancelled event note
580
+ "comment-<externalCommentId>" — Individual comment
581
+ "reply-<commentId>-<replyId>" — Reply to a comment
582
+ ```
583
+
584
+ ## HTML Content Handling
585
+
586
+ **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.
587
+
588
+ ### Pattern
589
+
590
+ ```typescript
591
+ // ✅ CORRECT: Pass raw HTML with contentType
592
+ const note = {
593
+ key: "description",
594
+ content: item.bodyHtml, // Raw HTML from API
595
+ contentType: "html" as const, // Server converts to markdown
596
+ };
597
+
598
+ // ✅ CORRECT: Use plain text when that's what you have
599
+ const note = {
600
+ key: "description",
601
+ content: item.bodyText,
602
+ contentType: "text" as const,
603
+ };
604
+
605
+ // ❌ WRONG: Stripping HTML locally
606
+ const stripped = html.replace(/<[^>]+>/g, " ").trim();
607
+ const note = { content: stripped }; // Broken encoding, lost links
608
+ ```
609
+
610
+ ### When APIs provide both HTML and plain text
611
+
612
+ 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.
613
+
614
+ ```typescript
615
+ function extractBody(part: MessagePart): { content: string; contentType: "text" | "html" } {
616
+ // Prefer HTML for server-side conversion
617
+ const htmlPart = findPart(part, "text/html");
618
+ if (htmlPart) return { content: decode(htmlPart), contentType: "html" };
619
+
620
+ const textPart = findPart(part, "text/plain");
621
+ if (textPart) return { content: decode(textPart), contentType: "text" };
622
+
623
+ return { content: "", contentType: "text" };
624
+ }
625
+ ```
626
+
627
+ ### Previews
628
+
629
+ 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.
630
+
631
+ ### ContentType values
632
+
633
+ | Value | Meaning |
634
+ |-------|---------|
635
+ | `"text"` | Plain text — auto-links URLs, preserves line breaks |
636
+ | `"markdown"` | Already markdown (default if omitted) |
637
+ | `"html"` | HTML — converted to markdown server-side |
638
+
639
+ ## Sync Metadata Injection
640
+
641
+ **Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled):
642
+
643
+ ```typescript
644
+ activity.meta = {
645
+ ...activity.meta,
646
+ syncProvider: "myprovider", // Provider identifier
647
+ channelId: resourceId, // Resource being synced
648
+ };
649
+ ```
650
+
651
+ This metadata is used by the twist's `onChannelDisabled` callback to match and archive activities:
652
+
653
+ ```typescript
654
+ // In the twist:
655
+ async onChannelDisabled(filter: ActivityFilter): Promise<void> {
656
+ await this.tools.plot.updateActivity({ match: filter, archived: true });
657
+ }
658
+ ```
659
+
660
+ ## Initial vs. Incremental Sync (REQUIRED)
661
+
662
+ **Every source 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.
663
+
664
+ | Field | Initial Sync | Incremental Sync | Reason |
665
+ |-------|-------------|------------------|--------|
666
+ | `unread` | `false` | *omit* | Initial: mark all read. Incremental: auto-mark read for author only |
667
+ | `archived` | `false` | *omit* | Unarchive on install, preserve user choice on updates |
668
+
669
+ ```typescript
670
+ const activity = {
671
+ // ...
672
+ ...(initialSync ? { unread: false } : {}),
673
+ ...(initialSync ? { archived: false } : {}),
674
+ };
675
+ ```
676
+
677
+ ### How to propagate the flag
678
+
679
+ 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:
680
+
681
+ **Pattern A: Store in SyncState** (used in the scaffold above)
682
+
683
+ 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`.
684
+
685
+ **Pattern B: Pass as callback argument** (used by sources like Gmail that don't store `initialSync` in state)
686
+
687
+ Pass `initialSync` as an explicit argument through `this.callback()`:
688
+
689
+ ```typescript
690
+ // onChannelEnabled — initial sync
691
+ const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true);
692
+
693
+ // startIncrementalSync — not initial
694
+ const syncCallback = await this.callback(this.syncBatch, 1, "incremental", channelId, false);
695
+
696
+ // syncBatch — accept and propagate the flag
697
+ async syncBatch(
698
+ batchNumber: number,
699
+ mode: "full" | "incremental",
700
+ channelId: string,
701
+ initialSync?: boolean // optional for backward compat with old serialized callbacks
702
+ ): Promise<void> {
703
+ const isInitial = initialSync ?? (mode === "full"); // safe default for old callbacks
704
+ // ... pass isInitial to processItems and to next batch callback
705
+ }
706
+ ```
707
+
708
+ **Whichever pattern you use, verify that ALL entry points set the flag correctly:**
709
+ - `onChannelEnabled` → `true` (first import)
710
+ - `startSync` → `true` (manual full sync)
711
+ - Webhook / incremental handler → `false`
712
+ - Next batch callback → propagate current value
713
+
714
+ ## Webhook Patterns
715
+
716
+ ### Localhost Guard (REQUIRED)
717
+
718
+ All sources MUST skip webhook registration in local development:
719
+
720
+ ```typescript
721
+ const webhookUrl = await this.tools.network.createWebhook({}, this.onWebhook, resourceId);
722
+
723
+ if (webhookUrl.includes("localhost") || webhookUrl.includes("127.0.0.1")) {
724
+ return; // Skip — webhooks can't reach localhost
725
+ }
726
+ ```
727
+
728
+ ### Webhook Verification
729
+
730
+ Verify webhook signatures to prevent unauthorized calls. Each provider has its own method:
731
+
732
+ | Provider | Method |
733
+ |----------|--------|
734
+ | Linear | `LinearWebhookClient` from `@linear/sdk/webhooks` |
735
+ | Slack | Challenge response + event type filtering |
736
+ | Google | UUID secret in channel token query |
737
+ | Microsoft | Subscription `clientState` |
738
+ | Asana | HMAC-SHA256 via `crypto.subtle` |
739
+
740
+ ### Watch Renewal (Calendar/Drive)
741
+
742
+ For providers that expire watches, schedule proactive renewal:
743
+
744
+ ```typescript
745
+ private async scheduleWatchRenewal(resourceId: string): Promise<void> {
746
+ const expiresAt = /* watch expiry from provider */;
747
+ const renewalTime = new Date(expiresAt.getTime() - 24 * 60 * 60 * 1000); // 24h before
748
+
749
+ const renewalCallback = await this.callback(this.renewWatch, resourceId);
750
+ const taskToken = await this.runTask(renewalCallback, { runAt: renewalTime });
751
+ if (taskToken) await this.set(`watch_renewal_task_${resourceId}`, taskToken);
752
+ }
753
+ ```
754
+
755
+ ## Bidirectional Sync
756
+
757
+ For sources that support write-backs (updating external items from Plot):
758
+
759
+ ### Issue/Task Updates (`updateIssue`)
760
+
761
+ ```typescript
762
+ async updateIssue(activity: Activity): Promise<void> {
763
+ const externalId = activity.meta?.externalId as string;
764
+ if (!externalId) throw new Error("External ID not found in meta");
765
+
766
+ const client = await this.getClient(activity.meta?.resourceId as string);
767
+ await client.updateItem(externalId, {
768
+ title: activity.title,
769
+ done: activity.type === ActivityType.Action ? activity.done : undefined,
770
+ });
771
+ }
772
+ ```
773
+
774
+ ### Comment Sync (`addIssueComment`)
775
+
776
+ ```typescript
777
+ async addIssueComment(meta: ActivityMeta, body: string, noteId?: string): Promise<string | void> {
778
+ const externalId = meta.externalId as string;
779
+ if (!externalId) throw new Error("External ID not found");
780
+
781
+ const client = await this.getClient(meta.resourceId as string);
782
+ const comment = await client.createComment(externalId, { body });
783
+ if (comment?.id) return `comment-${comment.id}`;
784
+ }
785
+ ```
786
+
787
+ ### Loop Prevention
788
+
789
+ The parent twist prevents infinite loops by checking note authorship:
790
+
791
+ ```typescript
792
+ // In the twist (not the source):
793
+ async onNoteCreated(note: Note): Promise<void> {
794
+ if (note.author.type === ActorType.Twist) return; // Prevent loops
795
+ // ... sync note to external service
796
+ }
797
+ ```
798
+
799
+ ## Contacts Pattern
800
+
801
+ Sources that sync user data should create contacts for authors and assignees:
802
+
803
+ ```typescript
804
+ import type { NewContact } from "@plotday/twister/plot";
805
+
806
+ const authorContact: NewContact | undefined = creator?.email ? {
807
+ email: creator.email,
808
+ name: creator.name,
809
+ avatar: creator.avatarUrl ?? undefined,
810
+ } : undefined;
811
+
812
+ const activity: NewActivityWithNotes = {
813
+ // ...
814
+ author: authorContact,
815
+ assignee: assigneeContact ?? null,
816
+ notes: [{
817
+ author: authorContact, // Note-level author too
818
+ // ...
819
+ }],
820
+ };
821
+ ```
822
+
823
+ Declare `ContactAccess.Write` in build:
824
+ ```typescript
825
+ plot: build(Plot, { contact: { access: ContactAccess.Write } }),
826
+ ```
827
+
828
+ ## Buffer Declaration
829
+
830
+ Cloudflare Workers provides `Buffer` globally, but TypeScript doesn't know about it. Declare it at the top of files that need it:
831
+
832
+ ```typescript
833
+ declare const Buffer: {
834
+ from(
835
+ data: string | ArrayBuffer | Uint8Array,
836
+ encoding?: string
837
+ ): Uint8Array & { toString(encoding?: string): string };
838
+ };
839
+ ```
840
+
841
+ ## Building and Testing
842
+
843
+ ```bash
844
+ # Build the source
845
+ cd public/sources/<name> && pnpm build
846
+
847
+ # Type-check without building
848
+ cd public/sources/<name> && pnpm exec tsc --noEmit
849
+
850
+ # Install dependencies (from repo root)
851
+ pnpm install
852
+ ```
853
+
854
+ After creating a new source, add it to `pnpm-workspace.yaml` if not already covered by the glob pattern.
855
+
856
+ ## Source Development Checklist
857
+
858
+ - [ ] Extend `Source<YourSource>`
859
+ - [ ] Declare `static readonly PROVIDER`, `static readonly SCOPES`
860
+ - [ ] Declare `static readonly Options: SyncToolOptions` and `declare readonly Options: SyncToolOptions`
861
+ - [ ] Declare all dependencies in `build()`: Integrations, Network, Callbacks, Tasks, Plot
862
+ - [ ] Implement `getChannels()`, `onChannelEnabled()`, `onChannelDisabled()`
863
+ - [ ] Convert parent callbacks to tokens with `createFromParent()` — **never pass functions to `this.callback()`**
864
+ - [ ] Store callback tokens with `this.set()`, retrieve with `this.get<Callback>()`
865
+ - [ ] Pass only serializable values (no functions, no undefined) to `this.callback()`
866
+ - [ ] Implement batch sync with `this.tools.tasks.runTask()` for fresh request limits
867
+ - [ ] Add localhost guard in webhook setup
868
+ - [ ] Verify webhook signatures
869
+ - [ ] Use canonical `source` URLs for activity upserts (immutable IDs)
870
+ - [ ] Use `note.key` for note-level upserts
871
+ - [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally**
872
+ - [ ] Inject `syncProvider` and `channelId` into `activity.meta`
873
+ - [ ] Set `created` on notes using the external system's timestamp (not sync time)
874
+ - [ ] 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
875
+ - [ ] Create contacts for authors/assignees with `NewContact`
876
+ - [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()`
877
+ - [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export
878
+ - [ ] Verify the source builds: `pnpm build`
879
+
880
+ ## Common Pitfalls
881
+
882
+ 1. **❌ Passing functions to `this.callback()`** — Convert to tokens first with `createFromParent()`
883
+ 2. **❌ Storing functions with `this.set()`** — Convert to tokens first
884
+ 3. **❌ Not validating callback token exists** — Always check before `callbacks.run()`
885
+ 4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta`
886
+ 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
887
+ 6. **❌ Using mutable IDs in `source`** — Use immutable IDs (Jira issue ID, not issue key)
888
+ 7. **❌ Not breaking loops into batches** — Each execution has ~1000 request limit
889
+ 8. **❌ Missing localhost guard** — Webhook registration fails silently on localhost
890
+ 9. **❌ Calling `plot.createThread()` from a source** — Sources save data directly via `integrations.saveLink()`
891
+ 10. **❌ Breaking callback signatures** — Old callbacks auto-upgrade; add optional params at end only
892
+ 11. **❌ Passing `undefined` in serializable values** — Use `null` instead
893
+ 12. **❌ Forgetting to clean up on disable** — Delete callbacks, webhooks, and stored state
894
+ 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)
895
+ 14. **❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side conversion. Local regex stripping breaks encoding and loses links
896
+ 15. **❌ 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"
897
+
898
+ ## Study These Examples
899
+
900
+ | Source | Category | Key Patterns |
901
+ |--------|----------|-------------|
902
+ | `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync |
903
+ | `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing |
904
+ | `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth |
905
+ | `gmail/` | MessagingSource | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern |
906
+ | `google-drive/` | DocumentSource | Document comments, reply threading, file watching |
907
+ | `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup |
908
+ | `asana/` | ProjectSource | HMAC webhook verification, section-based projects |
909
+ | `outlook-calendar/` | CalendarSource | Microsoft Graph API, subscription management |
910
+ | `google-contacts/` | (Supporting) | Contact sync, cross-source `syncWithAuth()` pattern |