You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Update docs for source-owned lifecycle and link support
Rename BUILDING_TOOLS.md to BUILDING_SOURCES.md. Update AGENTS guides,
templates, and docs to reflect lifecycle hooks moving to Twist/Source
base classes, link processing support, and source-owned channel
lifecycle. Update typedoc config.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -43,8 +43,9 @@ All types in `twister/src/` with full JSDoc:
43
43
2.**❌ Long-running operations without batching** — Break into chunks with `runTask()` (~1000 requests per execution)
44
44
3.**❌ Passing functions to `this.callback()`** — See `sources/AGENTS.md` for callback serialization pattern
45
45
4.**❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `thread.meta`
46
-
5.**❌ Not handling initial vs incremental sync** — `unread: false` for initial, omit for incremental
46
+
5.**❌ Not handling initial vs incremental sync** — Propagate `initialSync` flag from entry point (`onChannelEnabled` → `true`, webhook → `false`) through all batch callbacks. Set `unread: false` and `archived: false` for initial, omit for incremental
47
47
6.**❌ Missing localhost guard in webhooks** — Skip webhook registration when URL contains "localhost"
48
+
7.**❌ Stripping HTML tags locally** — Pass raw HTML with `contentType: "html"` for server-side markdown conversion
@@ -580,6 +581,61 @@ https://slack.com/app_redirect?channel=<id>&message_ts=<ts> — Slack uses full
580
581
"reply-<commentId>-<replyId>" — Reply to a comment
581
582
```
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"asconst, // Server converts to markdown
596
+
};
597
+
598
+
// ✅ CORRECT: Use plain text when that's what you have
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
+
583
639
## Sync Metadata Injection
584
640
585
641
**Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled):
**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.
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()`:
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
+
619
714
## Webhook Patterns
620
715
621
716
### Localhost Guard (REQUIRED)
@@ -773,8 +868,10 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove
773
868
- [ ] Verify webhook signatures
774
869
- [ ] Use canonical `source` URLs for activity upserts (immutable IDs)
775
870
- [ ] Use `note.key` for note-level upserts
871
+
- [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally**
776
872
- [ ] Inject `syncProvider` and `channelId` into `activity.meta`
777
-
- [ ] Handle `initialSync` flag: `unread: false`and`archived: false`forinitial, omit both for incremental
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
778
875
- [ ] Create contacts for authors/assignees with `NewContact`
779
876
- [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()`
780
877
- [ ] Add `package.json` with correct structure, `tsconfig.json`, and `src/index.ts` re-export
@@ -786,14 +883,17 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove
786
883
2. **❌ Storing functions with `this.set()`** — Convert to tokens first
787
884
3. **❌ Not validating callback token exists** — Always check before `callbacks.run()`
788
885
4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta`
11.**❌ Forgettingtocleanupondisable** — Deletecallbacks, webhooks, and stored state
796
-
12. **❌ Two-way sync without metadata correlation** — Embed Plot ID in external item metadata to prevent duplicates from race conditions (see SYNC_STRATEGIES.md §6)
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
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"
797
897
798
898
## Study These Examples
799
899
@@ -802,7 +902,7 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove
Copy file name to clipboardExpand all lines: twister/README.md
+4-2Lines changed: 4 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -115,7 +115,7 @@ async upgrade() // When new version is deployed
115
115
116
116
### Twist Tools
117
117
118
-
Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own. Use built-in tools or create your own.
118
+
Twist tools provide capabilities to twists. They are usually unopinionated and do nothing on their own.
119
119
120
120
**Built-in Tools:**
121
121
@@ -129,6 +129,8 @@ Twist tools provide capabilities to twists. They are usually unopinionated and d
129
129
130
130
[View all tools →](https://twist.plot.day/documents/Built-in_Tools.html)
131
131
132
+
External service integrations (Google Calendar, Slack, Linear, etc.) are built as **Sources** — see [Building Sources](https://twist.plot.day/documents/Building_Sources.html).
133
+
132
134
### Activities and Notes
133
135
134
136
**Activity** represents something done or to be done (a task, event, or conversation).
External service integrations (Google Calendar, Slack, Linear, etc.) are built as Sources. See the [Building Sources](https://twist.plot.day/documents/Building_Sources.html) guide.
0 commit comments