Skip to content

Commit 76fc6fe

Browse files
KrisBraunclaude
andcommitted
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>
1 parent 2602607 commit 76fc6fe

14 files changed

Lines changed: 583 additions & 1020 deletions

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ All types in `twister/src/` with full JSDoc:
3030
## Additional Resources
3131

3232
- **Full Documentation**: <https://twist.plot.day>
33-
- **Building Sources Guide**: `twister/docs/BUILDING_TOOLS.md`
33+
- **Building Sources Guide**: `sources/AGENTS.md`
3434
- **Runtime Environment**: `twister/docs/RUNTIME.md`
3535
- **Tools Guide**: `twister/docs/TOOLS_GUIDE.md`
3636
- **Multi-User Auth**: `twister/docs/MULTI_USER_AUTH.md`
@@ -43,8 +43,9 @@ All types in `twister/src/` with full JSDoc:
4343
2. **❌ Long-running operations without batching** — Break into chunks with `runTask()` (~1000 requests per execution)
4444
3. **❌ Passing functions to `this.callback()`** — See `sources/AGENTS.md` for callback serialization pattern
4545
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
4747
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
4849

4950
---
5051

sources/AGENTS.md

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ export class MySource extends Source<MySource> {
340340
notes: [{
341341
key: "description", // Enables note upsert
342342
content: item.description || null,
343+
contentType: item.descriptionHtml ? "html" as const : "text" as const,
343344
links: item.url ? [{
344345
type: LinkType.external,
345346
title: "Open in Service",
@@ -580,6 +581,61 @@ https://slack.com/app_redirect?channel=<id>&message_ts=<ts> — Slack uses full
580581
"reply-<commentId>-<replyId>" — Reply to a comment
581582
```
582583

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+
583639
## Sync Metadata Injection
584640

585641
**Every synced activity MUST include sync metadata** in `activity.meta` for bulk operations (e.g., archiving all activities when a sync is disabled):
@@ -601,7 +657,9 @@ async onChannelDisabled(filter: ActivityFilter): Promise<void> {
601657
}
602658
```
603659

604-
## Initial vs. Incremental Sync
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.
605663

606664
| Field | Initial Sync | Incremental Sync | Reason |
607665
|-------|-------------|------------------|--------|
@@ -616,6 +674,43 @@ const activity = {
616674
};
617675
```
618676

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+
619714
## Webhook Patterns
620715

621716
### Localhost Guard (REQUIRED)
@@ -773,8 +868,10 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove
773868
- [ ] Verify webhook signatures
774869
- [ ] Use canonical `source` URLs for activity upserts (immutable IDs)
775870
- [ ] Use `note.key` for note-level upserts
871+
- [ ] Set `contentType: "html"` on notes with HTML content — **never strip HTML locally**
776872
- [ ] Inject `syncProvider` and `channelId` into `activity.meta`
777-
- [ ] Handle `initialSync` flag: `unread: false` and `archived: false` for initial, 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
778875
- [ ] Create contacts for authors/assignees with `NewContact`
779876
- [ ] Clean up all stored state and callbacks in `stopSync()` and `onChannelDisabled()`
780877
- [ ] 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
786883
2. **❌ Storing functions with `this.set()`** — Convert to tokens first
787884
3. **❌ Not validating callback token exists** — Always check before `callbacks.run()`
788885
4. **❌ Forgetting sync metadata** — Always inject `syncProvider` and `channelId` into `activity.meta`
789-
5. **Using mutable IDs in `source`**Use immutable IDs (Jira issue ID, not issue key)
790-
6. **Not breaking loops into batches**Each execution has ~1000 request limit
791-
7. **Missing localhost guard**Webhook registration fails silently on localhost
792-
8. **Calling `plot.createThread()` from a source**Sources save data directly via `integrations.saveLink()`
793-
9. **Breaking callback signatures**Old callbacks auto-upgrade; add optional params at end only
794-
10. **Passing `undefined` in serializable values**Use `null` instead
795-
11. **Forgetting to clean up on disable**Delete callbacks, 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
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"
797897
798898
## Study These Examples
799899
@@ -802,7 +902,7 @@ After creating a new source, add it to `pnpm-workspace.yaml` if not already cove
802902
| `linear/` | ProjectSource | Clean reference implementation, webhook handling, bidirectional sync |
803903
| `google-calendar/` | CalendarSource | Recurring events, RSVP write-back, watch renewal, cross-source auth sharing |
804904
| `slack/` | MessagingSource | Team-sharded webhooks, thread model, Slack-specific auth |
805-
| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation |
905+
| `gmail/` | MessagingSource | PubSub webhooks, email thread transformation, HTML contentType, callback-arg initialSync pattern |
806906
| `google-drive/` | DocumentSource | Document comments, reply threading, file watching |
807907
| `jira/` | ProjectSource | Immutable vs mutable IDs, comment metadata for dedup |
808908
| `asana/` | ProjectSource | HMAC webhook verification, section-based projects |

twister/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ async upgrade() // When new version is deployed
115115

116116
### Twist Tools
117117

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.
119119

120120
**Built-in Tools:**
121121

@@ -129,6 +129,8 @@ Twist tools provide capabilities to twists. They are usually unopinionated and d
129129

130130
[View all tools →](https://twist.plot.day/documents/Built-in_Tools.html)
131131

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+
132134
### Activities and Notes
133135

134136
**Activity** represents something done or to be done (a task, event, or conversation).
@@ -206,7 +208,7 @@ plot priority create # Create new priority
206208
- [Core Concepts](https://twist.plot.day/documents/Core_Concepts.html) - Twists, tools, and architecture
207209
- [Sync Strategies](https://twist.plot.day/documents/Sync_Strategies.html) - Data synchronization patterns (upserts, deduplication, ID management)
208210
- [Built-in Tools](https://twist.plot.day/documents/Built-in_Tools.html) - Plot, Store, AI, and more
209-
- [Building Custom Tools](https://twist.plot.day/documents/Building_Custom_Tools.html) - Create reusable twist tools
211+
- [Building Sources](https://twist.plot.day/documents/Building_Sources.html) - Build external service integrations
210212
- [Runtime Environment](https://twist.plot.day/documents/Runtime_Environment.html) - Execution constraints and optimization
211213
- [Advanced Topics](https://twist.plot.day/documents/Advanced.html) - Complex patterns and techniques
212214

twister/cli/templates/AGENTS.template.md

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,7 @@ import {
7878
ThreadType,
7979
} from "@plotday/twister";
8080
import { ThreadAccess, Plot } from "@plotday/twister/tools/plot";
81-
// Import your tools:
82-
// import { GoogleCalendar } from "@plotday/tool-google-calendar";
83-
// import { Linear } from "@plotday/tool-linear";
81+
// Import your sources or tools as needed
8482

8583
export default class MyTwist extends Twist<MyTwist> {
8684
build(build: ToolBuilder) {
@@ -133,31 +131,6 @@ For complete API documentation of built-in tools including all methods, types, a
133131

134132
**Critical**: Never use instance variables for state. They are lost after function execution. Always use Store methods.
135133

136-
### External Tools (Add to package.json)
137-
138-
Add tool dependencies to `package.json`:
139-
140-
```json
141-
{
142-
"dependencies": {
143-
"@plotday/twister": "workspace:^",
144-
"@plotday/tool-google-calendar": "workspace:^"
145-
}
146-
}
147-
```
148-
149-
#### Available External Tools
150-
151-
- `@plotday/tool-google-calendar`: Google Calendar sync (CalendarTool)
152-
- `@plotday/tool-outlook-calendar`: Outlook Calendar sync (CalendarTool)
153-
- `@plotday/tool-google-contacts`: Google Contacts sync (supporting tool)
154-
- `@plotday/tool-google-drive`: Google Drive sync (DocumentTool)
155-
- `@plotday/tool-gmail`: Gmail sync (MessagingTool)
156-
- `@plotday/tool-slack`: Slack sync (MessagingTool)
157-
- `@plotday/tool-linear`: Linear sync (ProjectTool)
158-
- `@plotday/tool-jira`: Jira sync (ProjectTool)
159-
- `@plotday/tool-asana`: Asana sync (ProjectTool)
160-
161134
## Lifecycle Methods
162135

163136
### activate(priority: Pick<Priority, "id">)

twister/cli/templates/README.template.md

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,30 +68,9 @@ build(build: ToolBuilder) {
6868
- **Callbacks**: Create persistent function references for webhooks
6969
- **Network**: HTTP access permissions and webhook management
7070

71-
#### External Tools
71+
#### Sources
7272

73-
Add external tool dependencies to `package.json`:
74-
75-
```json
76-
{
77-
"dependencies": {
78-
"@plotday/twister": "workspace:^",
79-
"@plotday/tool-google-calendar": "workspace:^"
80-
}
81-
}
82-
```
83-
84-
Then use them in your twist:
85-
86-
```typescript
87-
import GoogleCalendarTool from "@plotday/tool-google-calendar";
88-
89-
build(build: ToolBuilder) {
90-
return {
91-
googleCalendar: build(GoogleCalendarTool),
92-
};
93-
}
94-
```
73+
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.
9574

9675
### Activity Types
9776

0 commit comments

Comments
 (0)