AbstractProtectedtoolsGets the initialized tools for this twist.
Returns a human-readable name for the connected account. Shown in the connections list and edit modal to identify this connection.
For OAuth connectors, this is typically the workspace or organization name (e.g., "Acme Corp" for a Linear workspace). For API key connectors, this could be the workspace name from the external service.
Override this in your connector to return a meaningful account name.
The authorization (null for no-provider connectors)
The access token (null for no-provider connectors)
Promise resolving to the account display name
AbstractgetReturns available channels for the authorized actor. Called after OAuth is complete, during the setup/edit modal.
The completed authorization with provider and actor info
The access token for making API calls
Promise resolving to available channels for the user to select
AbstractonCalled when a channel resource is enabled for syncing.
The framework dispatches this in three cases:
setChannels discovered a new channel on a
connection with auto_enable_new_channels set.onChannelEnabled for every
channel that was already enabled at the time of re-auth, with
context.recovering = true. See SyncContext.recovering.Implementations should be idempotent and overwrite stored state:
the same channel may receive multiple onChannelEnabled calls across
its lifetime. Use unconditional this.set() writes rather than
coalesce/skip-if-present logic so a recovery dispatch wipes stale
cursors and state from the prior session.
Sync state tracking is automatic. The framework stamps the connection as "syncing" when it dispatches this method and clears that state when:
tools.integrations.channelSyncCompleted(id)
once the initial backfill is done, ORIMPORTANT: This method runs inline in the HTTP request handler.
Any long-running work (webhook setup, API calls, sync) MUST be queued
as a separate task via this.runTask(), not executed inline. Blocking
here causes the client to spin waiting for the response.
Only lightweight operations should appear directly in this method:
this.set(), this.get(), this.callback(), and this.runTask().
The channel that was enabled
Optionalcontext: SyncContextOptional sync context (plan-based hints, recovery flag)
async onChannelEnabled(channel: Channel, context?: SyncContext): Promise<void> {
// Recovery: drop stale cursors so the next sync re-walks history.
if (context?.recovering) {
await this.clear(`last_sync_token_${channel.id}`);
}
await this.set(`sync_state_${channel.id}`, { channelId: channel.id });
// Queue sync as a task — do NOT use this.run() or call sync methods inline
const syncCallback = await this.callback(this.syncBatch, 1, "full", channel.id, true);
await this.runTask(syncCallback);
// Queue webhook setup as a task — do NOT call setupWebhook() inline
const webhookCallback = await this.callback(this.setupWebhook, channel.id);
await this.runTask(webhookCallback);
}
AbstractonCalled when a channel resource is disabled. Should stop sync, clean up webhooks, and remove state.
The channel that was disabled
Called when a link created by this connector is updated by the user. Override to write back changes to the external service (e.g., changing issue status in Linear when marked done in Plot).
The updated link
Called when a user creates a thread in Plot that should create a new item in this connector's external system.
A connector opts in to Plot-initiated creation by declaring a
compose block on the relevant LinkTypeConfig (see
ComposeConfig). When a user picks "Create new
Implementations should create the item in the external service and
return a NewLinkWithNotes describing the created item. The platform
attaches the returned link to the originating thread — do not call
integrations.saveLink yourself.
Returning null aborts creation silently (the thread is still saved
without a link).
The fields captured in Plot for the new item.
The link to attach, or null to abort creation.
Called when a note is created on a thread owned by this connector. Override to write back comments to the external service (e.g., adding a comment to a Linear issue).
Returning a string or NoteWriteBackResult links the Plot note
to its external counterpart. A plain string sets the note's key.
A NoteWriteBackResult additionally sets a sync baseline (via
externalContent) so the next sync-in can recognize the round-tripped
content and preserve Plot's formatted version. See
NoteWriteBackResult for details.
The created note
The thread the note belongs to (includes thread.meta with connector-specific data)
Optional note key or NoteWriteBackResult for external dedup + baseline tracking
Resolve a fileRef action's bytes for download. Called when a user opens
an attachment in Plot. Return either a redirect URL (preferred for sources
that issue signed URLs, like Linear S3 or Slack permalink_public) or a
streamed body (required when bytes are only reachable through an
authenticated API call, like Gmail attachments.get).
Opaque value the connector previously emitted on a fileRef action.
Either { redirectUrl } or { body, mimeType, fileName? }.
Called when a note on a thread owned by this connector is updated. Override to write back changes to the external service (e.g., syncing reaction tags as emoji reactions, or editing a comment whose content changed in Plot).
Return a NoteWriteBackResult with externalContent to update
the sync baseline after a successful write-back, so the next sync-in
recognizes the external version as already-seen and preserves Plot's
content.
The updated note (includes current tags)
The thread the note belongs to (includes thread.meta with connector-specific data)
Optional NoteWriteBackResult for baseline tracking
Called when a user reads or unreads a thread owned by this connector. Override to write back read status to the external service (e.g., marking an email as read in Gmail).
The thread that was read/unread (includes thread.meta with connector-specific data)
The user who performed the action
false when marked as read, true when marked as unread
Called when a user adds, removes, or changes the role of contacts on a
thread owned by this connector. Override on connectors whose source
supports mid-thread recipient changes (Gmail, IMAP, etc.). Connectors
that can't change recipients per-message (Slack, Linear) leave this as
the default no-op and should also declare
LinkTypeConfig.supportsContactChanges: false.
The dispatch fires after Plot has persisted the change. Connectors are
expected to reflect it on the next outbound note (e.g. building To/Cc/Bcc
headers from the current thread.contacts × thread.contactMeta) — this
callback is not the right place to send a standalone notification.
Called when a user marks or unmarks a thread as todo. Override to sync todo status to the external service (e.g., starring an email in Gmail when marked as todo).
The thread (includes thread.meta with connector-specific data)
The user who changed the todo status
true when marked as todo, false when done or removed
Additional context
Optionaldate?: DateThe todo date (when todo=true)
Called when a schedule contact's RSVP status changes on a thread owned by this connector. Override to sync RSVP changes back to the external calendar.
The thread (includes thread.meta with connector-specific data)
The schedule ID
The contact whose status changed
The new RSVP status ('attend', 'skip', or null)
The user who changed the status
Called when a user adds or removes a single emoji reaction on a note
(one event per (note, actor, emoji) state transition).
Dispatch is routed to the reacting user's own connector instance via
twist_instance_for_actor on note_reaction.actor_id, so this method
already runs under the reactor's auth. Fetch the API client with the
connector's normal token-fetch path (this.tools.integrations.get(...))
and the external write — e.g. Slack reactions.add — will be attributed
to the correct user. No actAs step required.
If the reacting user has no connection of this type, no dispatch fires for that reaction (it stays in Plot only).
Override to sync per-actor reactions back to the external system.
The note that was reacted on (partial; id, key, content populated)
The thread the note belongs to (partial; id, title, archived, meta populated)
The contact who added/removed the reaction
The emoji (Unicode grapheme or provider:workspace/name custom-emoji ref)
true if the reaction is now present, false if it was removed
Called when the connector is activated after OAuth is complete.
Connectors receive the authorization in addition to the activating actor.
When this runs, this.userId is already populated with the installing
user's ID.
Default implementation does nothing. Override for custom setup.
The activation context
Optionalauth?: AuthorizationThe completed OAuth authorization
Optionalactor?: ActorThe actor who activated the connector
AbstractbuildDeclares tool dependencies for this twist. Return an object mapping tool names to build() promises.
The build function to use for declaring dependencies
Object mapping tool names to tool promises
ProtectedcallbackCreates a persistent callback to a method on this twist.
ExtraArgs are strongly typed to match the method's signature. They must be serializable.
Promise resolving to a persistent callback token
Creates a persistent callback to a method on this twist.
ExtraArgs are strongly typed to match the method's signature. They must be serializable.
Promise resolving to a persistent callback token
ProtectedactionLike callback(), but for an Action, which receives the action as the first argument.
Promise resolving to a persistent callback token
ProtecteddeleteDeletes a specific callback by its token.
The callback token to delete
Promise that resolves when the callback is deleted
ProtecteddeleteDeletes all callbacks for this twist.
Promise that resolves when all callbacks are deleted
ProtectedrunExecutes a callback by its token inline in the current execution.
Use this.runTask() instead for batch continuations and long-running work.
this.run() executes inline, sharing the current request count (~1000 limit)
and blocking the HTTP response. This causes timeouts when used in lifecycle
methods like onChannelEnabled or syncBatch continuations.
this.run() is appropriate when you need the callback's return value —
e.g., running a parent callback token that returns data. For fire-and-forget
work, always prefer this.runTask().
The callback token to execute
Optional arguments to pass to the callback
Promise resolving to the callback result
ProtectedgetRetrieves a value from persistent storage by key.
Values are automatically deserialized using SuperJSON, which properly restores Date objects, Maps, Sets, and other complex types.
The expected type of the stored value (must be Serializable)
The storage key to retrieve
Promise resolving to the stored value or null
ProtectedsetStores a value in persistent storage.
The value will be serialized using SuperJSON and stored persistently. SuperJSON automatically handles Date objects, Maps, Sets, undefined values, and other complex types that standard JSON doesn't support.
Important: Functions and Symbols cannot be stored. For function references: Use callbacks instead of storing functions directly.
The type of value being stored (must be Serializable)
The storage key to use
The value to store (must be SuperJSON-serializable)
Promise that resolves when the value is stored
// ✅ Date objects are preserved
await this.set("sync_state", {
lastSync: new Date(),
minDate: new Date(2024, 0, 1)
});
// ✅ undefined is now supported
await this.set("data", { name: "test", optional: undefined });
// ❌ WRONG: Cannot store functions directly
await this.set("handler", this.myHandler);
// ✅ CORRECT: Create a callback token first
const token = await this.callback(this.myHandler, "arg1", "arg2");
await this.set("handler_token", token);
// Later, execute the callback
const token = await this.get<string>("handler_token");
await this.run(token, args);
ProtectedclearRemoves a specific key from persistent storage.
The storage key to remove
Promise that resolves when the key is removed
ProtectedclearRemoves all keys from this twist's storage.
Promise that resolves when all keys are removed
ProtectedrunQueues a callback to execute in a separate worker context.
The callback token created with this.callback()
Optionaloptions: { runAt?: Date }Optional configuration for the execution
OptionalrunAt?: DateIf provided, schedules execution at this time; otherwise runs immediately
Promise resolving to a cancellation token (only for scheduled executions)
ProtectedcancelCancels a previously scheduled execution.
The cancellation token returned by runTask() with runAt option
Promise that resolves when the cancellation is processed
ProtectedcancelCancels all scheduled executions for this twist.
Promise that resolves when all cancellations are processed
Called when a new version of the twist is deployed.
This method should contain migration logic for updating old data structures or setting up new resources that weren't needed by the previous version. It is called once per active twist_instance with the new version.
Promise that resolves when upgrade is complete
Called when the twist's options configuration changes.
Override to react to option changes, e.g. archiving items when a sync type is toggled off, or starting sync when a type is toggled on.
The previously resolved options
The newly resolved options
Promise that resolves when the change is handled
Called when the twist is uninstalled.
This method should contain cleanup logic such as removing webhooks, cleaning up external resources, or performing final data operations.
Promise that resolves when deactivation is complete
Static ReadonlyisStatic marker to identify Connector subclasses without instanceof checks across worker boundaries.
Optional ReadonlyproviderThe OAuth provider this connector authenticates with.
Optional ReadonlyscopesOAuth scopes to request for this connector — a flat list (all required), or a ScopeConfig declaring required + optional scope groups.
Optional ReadonlysharedWhen true, one credential is shared across all users in the workspace, entered once by the installer. When false (default), each user provides their own credential.
Applies to both OAuth and key-based connectors:
Optional ReadonlykeyThe Options field name that contains the authentication key (e.g. "apiKey").
Must reference a secure: true field in the Options schema.
When set, this connector uses key-based auth instead of OAuth.
For individual connectors (shared is false), this field is stored
per-user rather than in shared config.
Optional ReadonlysingleWhen true, this connector has a single implicit channel.
getChannels() must return exactly one Channel.
The UI will show channel config inline instead of a channel list.
Optional ReadonlylinkRegistry of link types this connector creates (e.g., issue, event, message). Used for display in the UI (icons, labels, statuses).
Optional ReadonlyreactionDeclares how this connector's platform handles emoji reactions. Used to filter the reaction picker for notes whose primary connector is this one, and to guard outbound dispatch from sending emoji the platform can't accept.
Leave undefined for connectors whose platform has no concept of reactions (calendar, file storage, issue trackers without reactions).
Static Optional ReadonlyhandleWhen true, this connector is mentioned by default on replies to threads it created. When false (default), this connector cannot be mentioned at all.
Set this to true for connectors with bidirectional sync (e.g., issue trackers, messaging) where user replies should be written back to the external service.
Static Optional ReadonlymultipleWhen true, users may install multiple instances of this twist within
the same scope (personal workspace or team). Each instance must have a
distinct name.
Defaults to false (single instance per scope).
ProtecteduserThe user ID (twist_instance.owner_id) that installed this twist.
Populated by the runtime before any lifecycle method runs.
Protectedid
Base class for connectors — twists that sync data from external services.
Connectors declare a single OAuth provider and scopes, and implement channel lifecycle methods for discovering and syncing external resources. They save data directly via
integrations.saveLink()instead of using the Plot tool.Example