Creating Plot Twists
    Preparing search index...

    Class Connector<TSelf>Abstract

    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.

    class LinearConnector extends Connector<LinearConnector> {
    readonly provider = AuthProvider.Linear;
    readonly scopes = ["read", "write"];
    readonly linkTypes = [{
    type: "issue",
    label: "Issue",
    statuses: [
    { status: "open", label: "Open", icon: "todo" },
    { status: "done", label: "Done", icon: "done", done: true },
    ],
    }];

    build(build: ToolBuilder) {
    return {
    integrations: build(Integrations),
    };
    }

    async getChannels(auth: Authorization, token: AuthToken): Promise<Channel[]> {
    const teams = await this.listTeams(token);
    return teams.map(t => ({ id: t.id, title: t.name }));
    }

    async onChannelEnabled(channel: Channel) {
    const issues = await this.fetchIssues(channel.id);
    for (const issue of issues) {
    await this.tools.integrations.saveLink(issue);
    }
    }

    async onChannelDisabled(channel: Channel) {
    // Clean up webhooks, sync state, etc.
    }
    }

    Type Parameters

    • TSelf

    Hierarchy (View Summary)

    Index

    Accessors

    Constructors

    Methods

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

      Parameters

      • auth: Authorization | null

        The authorization (null for no-provider connectors)

      • token: AuthToken | null

        The access token (null for no-provider connectors)

      Returns Promise<string | null>

      Promise resolving to the account display name

    • Returns a matchable identity email for the connected account, for connectors that authenticate without OAuth (e.g. a username/app-password flow) and so have no provider-verified identity to fall back on.

      Unlike getAccountName, which is only a display label, the value returned here is used to recognize the connected account as belonging to the signed-in Plot user (for example, so the platform can tell the account's own messages apart from messages sent to it).

      Return null (the default) if the connector has no email address that can reliably identify the account.

      Returns Promise<{ email: string } | null>

      Promise resolving to the account's identity email, or null

    • Returns available channels for the authorized actor. Called after OAuth is complete, during the setup/edit modal.

      Parameters

      • auth: Authorization | null

        The completed authorization with provider and actor info

      • token: AuthToken | null

        The access token for making API calls

      Returns Promise<Channel[]>

      Promise resolving to available channels for the user to select

    • Called when a channel resource is enabled for syncing.

      The framework dispatches this in three cases:

      1. Initial enable — user toggled the channel on for the first time.
      2. Auto-enablesetChannels discovered a new channel on a connection with auto_enable_new_channels set.
      3. Recovery after re-auth — the user re-authorized a previously- broken connection. The framework calls 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:

      • the connector calls tools.integrations.channelSyncCompleted(id) once the initial backfill is done, OR
      • this method throws an unhandled exception (auto-cleared so the UI doesn't get stuck in "syncing" forever).

      IMPORTANT: 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().

      Parameters

      • channel: Channel

        The channel that was enabled

      • Optionalcontext: SyncContext

        Optional sync context (plan-based hints, recovery flag)

      Returns Promise<void>

      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);
      }
    • Called when a channel resource is disabled. Should stop sync, clean up webhooks, and remove state.

      Parameters

      • channel: Channel

        The channel that was disabled

      Returns Promise<void>

    • 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).

      Parameters

      • link: Link

        The updated link

      Returns Promise<void>

    • 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 " from the Add link modal and the thread is synced, the runtime calls this method with the draft fields.

      Implementations should create the item in the external service and return a CreateLinkResult describing the created item. The platform attaches the returned link to the originating thread — do not call integrations.saveLink yourself. channelId may be omitted; the platform auto-fills it from the compose draft.

      Returning null aborts creation silently (the thread is still saved without a link).

      Parameters

      Returns Promise<CreateLinkResult | null>

      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.

      Parameters

      • note: Note

        The created note

      • thread: ThreadFields

        The thread the note belongs to (includes thread.meta with connector-specific data)

      Returns Promise<string | void | NoteWriteBackResult>

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

      Parameters

      • ref: string

        Opaque value the connector previously emitted on a fileRef action.

      • OptionallinkMeta: Record<string, unknown> | null

        The meta of the link owning the fileRef's note — the same connector-authored metadata surfaced as thread.meta in write-back callbacks (e.g. chatId/channelId for chat connectors). null when the note has no link or the link carries no meta. Connectors whose refs are self-contained can ignore it.

      Returns Promise<
          | { redirectUrl: string }
          | {
              body: Uint8Array<ArrayBufferLike> | ReadableStream<any>;
              mimeType: string;
              fileName?: string;
          },
      >

      Either { redirectUrl } or { body, mimeType, fileName? }.

      If the source is unavailable, the connection is broken, or ref is invalid.

      If not overridden, fileRef actions on this connector's notes will return 410 Gone.

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

      Parameters

      • note: Note

        The updated note (includes current tags)

      • thread: ThreadFields

        The thread the note belongs to (includes thread.meta with connector-specific data)

      Returns Promise<void | NoteWriteBackResult>

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

      Parameters

      • thread: ThreadFields

        The thread that was read/unread (includes thread.meta with connector-specific data)

      • actor: Actor

        The user who performed the action

      • unread: boolean

        false when marked as read, true when marked as unread

      Returns Promise<void>

    • Called when a user changes the thread-level sharing of a thread owned by this connector — adding or removing a contact, or (for connectors with roles) changing a contact's role. Override on connectors whose external source can reflect that membership change, e.g. a group DM / multi-party chat (LinkTypeConfig.sharingModel: "thread") or an email's To/Cc/Bcc recipients (sharingModel: "message"). Connectors backed by an immutable roster (most group DMs today) or by channel-level membership (sharingModel: "channel") leave this as the default no-op.

      role/from/to are null for connectors without roles (group DMs have no roles); they carry a contactRoles id only for connectors that declare roles (e.g. email to/cc/bcc).

      The dispatch fires after Plot has persisted the change. A connector may reflect it actively (e.g. add/remove a participant on the external chat) or passively 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.

      Parameters

      • thread: ThreadFields

        The thread whose contacts changed

      • changes: {
            added: { contact: Contact; role: string | null }[];
            removed: { contact: Contact; role: string | null }[];
            changed: { contact: Contact; from: string | null; to: string | null }[];
        }

        The added/removed contacts and any role transitions on existing contacts

      Returns Promise<void>

    • 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).

      Parameters

      • thread: ThreadFields

        The thread (includes thread.meta with connector-specific data)

      • actor: Actor

        The user who changed the todo status

      • todo: boolean

        true when marked as todo, false when done or removed

      • options: { date?: Date }

        Additional context

        • Optionaldate?: Date

          The todo date (when todo=true)

      Returns Promise<void>

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

      Parameters

      • thread: ThreadFields

        The thread (includes thread.meta with connector-specific data)

      • scheduleId: string

        The schedule ID

      • contactId: ActorId

        The contact whose status changed

      • status: ScheduleContactStatus | null

        The new RSVP status ('attend', 'skip', or null)

      • actor: Actor

        The user who changed the status

      Returns Promise<void>

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

      Parameters

      • note: Note

        The note that was reacted on (partial; id, key, content populated)

      • thread: ThreadFields

        The thread the note belongs to (partial; id, title, archived, meta populated)

      • actor: Actor

        The contact who added/removed the reaction

      • emoji: string

        The emoji (Unicode grapheme or provider:workspace/name custom-emoji ref)

      • added: boolean

        true if the reaction is now present, false if it was removed

      Returns Promise<void>

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

      Parameters

      • context: { auth?: Authorization; actor?: Actor }

        The activation context

        • Optionalauth?: Authorization

          The completed OAuth authorization

        • Optionalactor?: Actor

          The actor who activated the connector

      Returns Promise<void>

    • Declares tool dependencies for this twist. Return an object mapping tool names to build() promises.

      Parameters

      • build: ToolBuilder

        The build function to use for declaring dependencies

      Returns Record<string, Promise<ITool>>

      Object mapping tool names to tool promises

      build(build: ToolBuilder) {
      return {
      plot: build(Plot),
      calendar: build(GoogleCalendar, { apiKey: "..." }),
      };
      }
    • Creates a persistent callback to a method on this twist.

      ExtraArgs are strongly typed to match the method's signature. They must be serializable.

      Type Parameters

      Parameters

      • fn: Fn

        The method to callback

      • ...extraArgs: TArgs

        Additional arguments to pass (type-checked, must be serializable)

      Returns Promise<Callback>

      Promise resolving to a persistent callback token

      const callback = await this.callback(this.onWebhook, "calendar", 123);
      
    • Creates a persistent callback to a method on this twist.

      ExtraArgs are strongly typed to match the method's signature. They must be serializable.

      Type Parameters

      Parameters

      • fn: Fn

        The method to callback

      • ...extraArgs: TArgs

        Additional arguments to pass (type-checked, must be serializable)

      Returns Promise<Callback>

      Promise resolving to a persistent callback token

      const callback = await this.callback(this.onWebhook, "calendar", 123);
      
    • Like callback(), but for an Action, which receives the action as the first argument.

      Type Parameters

      Parameters

      • fn: Fn

        The method to callback

      • ...extraArgs: TArgs

        Additional arguments to pass after the action

      Returns Promise<Callback>

      Promise resolving to a persistent callback token

      const callback = await this.actionCallback(this.doSomething, 123);
      const action: Action = {
      type: ActionType.callback,
      title: "Do Something",
      callback,
      };
    • Deletes a specific callback by its token.

      Parameters

      • token: Callback

        The callback token to delete

      Returns Promise<void>

      Promise that resolves when the callback is deleted

    • Deletes all callbacks for this twist.

      Returns Promise<void>

      Promise that resolves when all callbacks are deleted

    • Executes 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().

      Parameters

      • token: Callback

        The callback token to execute

      • ...args: []

        Optional arguments to pass to the callback

      Returns Promise<any>

      Promise resolving to the callback result

    • Retrieves a value from persistent storage by key.

      Values are automatically deserialized using SuperJSON, which properly restores Date objects, Maps, Sets, and other complex types.

      Type Parameters

      • T extends Serializable

        The expected type of the stored value (must be Serializable)

      Parameters

      • key: string

        The storage key to retrieve

      Returns Promise<T | null>

      Promise resolving to the stored value or null

    • Stores 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.

      Type Parameters

      • T extends Serializable

        The type of value being stored (must be Serializable)

      Parameters

      • key: string

        The storage key to use

      • value: T

        The value to store (must be SuperJSON-serializable)

      Returns Promise<void>

      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<Callback>("handler_token");
      await this.run(token);
    • Stores many key/value pairs in one round-trip. Always prefer this over looping set() for batch writes — each set() is a network round-trip. Atomic: either every entry lands or none do. See Store.setMany.

      Type Parameters

      • T extends Serializable

        The type of values being stored (must be Serializable)

      Parameters

      • entries: [key: string, value: T][]

        Array of [key, value] pairs to store

      Returns Promise<void>

      Promise that resolves when all values are stored

    • Reads many keys in one round-trip. Always prefer this over looping get(). Results are positionally aligned with keys; missing keys are null. See Store.getMany.

      Type Parameters

      Parameters

      • keys: string[]

        The storage keys to read

      Returns Promise<(T | null)[]>

      Promise resolving to one value (or null) per requested key

    • Lists matching keys with their values in one round-trip — the read counterpart of setMany. Replaces list() + a get() per key, which costs 1 + N round-trips. See Store.listEntries.

      Type Parameters

      Parameters

      • prefix: string

        The prefix to match keys against

      Returns Promise<[key: string, value: T][]>

      Promise resolving to [key, value] pairs, key-ascending

    • Removes a specific key from persistent storage.

      Parameters

      • key: string

        The storage key to remove

      Returns Promise<void>

      Promise that resolves when the key is removed

    • Removes many keys in one round-trip. Pair with listEntries so a drain costs two round-trips regardless of key count. Atomic. See Store.clearMany.

      Parameters

      • keys: string[]

        The storage keys to remove

      Returns Promise<void>

      Promise that resolves when all keys are removed

    • Removes all keys from this twist's storage.

      Returns Promise<void>

      Promise that resolves when all keys are removed

    • Queues a callback to execute in a separate worker context.

      Parameters

      • callback: Callback

        The callback token created with this.callback()

      • Optionaloptions: { runAt?: Date }

        Optional configuration for the execution

        • OptionalrunAt?: Date

          If provided, schedules execution at this time; otherwise runs immediately

      Returns Promise<string | void>

      Promise resolving to a cancellation token (only for scheduled executions)

    • Cancels a previously scheduled execution.

      Parameters

      • token: string

        The cancellation token returned by runTask() with runAt option

      Returns Promise<void>

      Promise that resolves when the cancellation is processed

    • Cancels all scheduled executions for this twist.

      Returns Promise<void>

      Promise that resolves when all cancellations are processed

    • Schedules a singleton task keyed by key: re-scheduling under the same key atomically replaces any pending task, so at most one is ever live.

      Prefer this over runTask({ runAt }) for recurring/self-renewing jobs (watch renewals, polling, deferred cleanup) — it removes the error-prone "store token, cancel before re-scheduling" bookkeeping that otherwise leaks parallel task chains. See Tasks.scheduleTask.

      With coalesce: true, an existing pending task is kept instead of replaced (its fire time is pulled earlier, never pushed later) — use for high-frequency triggers like webhook-driven sync scheduling; the passed callback may be discarded, so don't reuse its token.

      Parameters

      • key: string

        Stable identifier scoped to what the task renews

      • callback: Callback

        The callback token created with this.callback()

      • options: { runAt: Date; coalesce?: boolean }
        • runAt: Date

          When to run (required)

        • Optionalcoalesce?: boolean

          Keep an existing pending task (earliest wins)

      Returns Promise<string | void>

      Promise resolving to the scheduled task's cancellation token

    • Cancels the singleton task previously scheduled under key (if any). No-op if none exists or it already ran. See Tasks.cancelScheduledTask.

      Parameters

      Returns Promise<void>

      Promise that resolves when the cancellation is processed

    • Schedules a durable recurring task under a stable key. The platform re-arms the task every intervalMs automatically — the callback does NOT need to reschedule itself. Re-scheduling under the same key atomically replaces the pending occurrence (at most one live task per key). Tear down with cancelScheduledTask. See Tasks.scheduleRecurring.

      Parameters

      • key: string

        Stable identifier, e.g. "mailbox-self-heal"

      • callback: Callback

        Callback token created with this.callback()

      • options: { intervalMs: number; firstRunAt?: Date }
        • intervalMs: number

          Safety-ceiling cadence in milliseconds

        • OptionalfirstRunAt?: Date

          Optional precise time for the next fire

      Returns Promise<void>

    • Record dirty items and ensure a bounded drain pass runs soon — THE pattern for webhook-driven sync and any other high-frequency "something changed" trigger.

      A burst of calls under the same key collapses into ONE pending pass (never one queued task per notification); ids are persisted durably and released only after the handler processes them (at-least-once, race-free under concurrent deliveries); each pass hands the handler at most batchSize ids, with the platform scheduling continuations while a backlog remains; ids that keep failing are dropped after maxAttempts passes so one poison item can't wedge the drain.

      The handler must be a named method on this class (like this.callback targets). It receives the ids slice — or [] for signal-only drains (omit ids) where it derives its own work from a cursor or time window.

      Parameters

      Returns Promise<void>

      async onWebhook(request: WebhookRequest): Promise<void> {
      const ids = parseChangedIds(request);
      await this.scheduleDrain("incremental-sync", this.drainChanges, { ids });
      }

      async drainChanges(ids: string[]): Promise<void> {
      for (const id of ids) await this.syncItem(id); // ≤ batchSize items
      }

      Tear down with cancelDrain (e.g. in onChannelDisabled).

    • Cancel the pending drain pass for key and discard its recorded ids. Use in teardown paths. See scheduleDrain.

      Parameters

      • key: string

      Returns Promise<void>

    • SDK-internal: executes one bounded drain pass (the scheduled-task target behind scheduleDrain). Public only so the task runtime can dispatch to it by name — do not call or override.

      Parameters

      • key: string
      • handlerName: string
      • options: { batchSize: number; delayMs: number; maxAttempts: number }

      Returns Promise<void>

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

      Returns Promise<void>

      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.

      Parameters

      • oldOptions: Record<string, any>

        The previously resolved options

      • newOptions: Record<string, any>

        The newly resolved options

      Returns Promise<void>

      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.

      Returns Promise<void>

      Promise that resolves when deactivation is complete

    • Called when a thread created by this twist is updated. Override to implement two-way sync with an external system.

      Parameters

      • thread: ThreadFields

        The updated thread

      • changes: { tagsAdded: Record<Tag, ActorId[]>; tagsRemoved: Record<Tag, ActorId[]> }

        Tag additions and removals on the thread

      Returns Promise<void>

    • Called when a link is created in a connected source channel. Requires link: true in Plot options.

      Parameters

      • link: Link

        The newly created link

      • notes: Note[]

        Notes on the link's thread

      Returns Promise<void>

    • Called when a note is created on a thread with a link from a connected channel. Requires link: true in Plot options.

      Parameters

      • note: Note

        The newly created note

      • link: Link

        The link associated with the thread

      Returns Promise<void>

    Properties

    isConnector: true

    Static marker to identify Connector subclasses without instanceof checks across worker boundaries.

    provider?: AuthProvider

    The OAuth provider this connector authenticates with.

    scopes?: string[] | ScopeConfig

    OAuth scopes to request for this connector — a flat list (all required), or a ScopeConfig declaring required + optional scope groups.

    access?: string[]

    Plain-language bullets describing what access connecting this service grants the user — shown on the connect screen regardless of auth mechanism (OAuth, API key, or hosted). For OAuth connectors it also previews what the provider's consent screen will request. These are justifications for what Plot accesses, not a one-to-one mapping of scope strings.

    shared?: boolean

    When 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:

    • Shared OAuth: e.g. Slack bot token (workspace-level)
    • Shared key: e.g. Attio workspace API key
    • Individual OAuth: e.g. Google Calendar (per-user)
    • Individual key: e.g. Fellow (per-user API key)
    keyOption?: string

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

    singleChannel?: boolean

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

    channelNoun?: { singular: string; plural: string }

    The user-facing noun for this connector's channels — what each Channel returned by getChannels actually represents in the external service. Many connectors map "channels" onto a domain concept (folders, projects, calendars, labels, spaces, repositories, …), so the generic word "channel" reads as jargon. Set this and the UI substitutes it everywhere it would otherwise say "channel(s)" — e.g. the per-connection toggle becomes "Sync new folders" / "When a new folder is added, …".

    Provide lowercase nouns (the UI capitalizes where needed): { singular: "folder", plural: "folders" }. Defaults to { singular: "channel", plural: "channels" } when omitted.

    autoEnableNewChannelsByDefault?: boolean

    Whether the per-connection "Sync new channels" preference starts ON for newly added connections of this connector. Defaults to false (opt-in).

    Set true for connectors that select all of their channels by default (i.e. getChannels returns no channels marked enabledByDefault: false). If syncing every channel is the intended default, then channels discovered later should also sync automatically. Leave false/omitted for selective connectors that exclude some channels by default (e.g. Gmail syncs only Inbox/Sent, Google Calendar only owner calendars) — for those, a newly discovered channel is just as uncertain and should wait for the user to opt in.

    Only affects the default for new connections; the user's explicit toggle always wins, and existing connections keep their stored preference.

    fixedChannels?: boolean

    Whether this connector's channel set is fixed — getChannels always returns the same enumerated channels and no new channels ever appear dynamically over time. Set true for connectors whose channels are a built-in, closed set (e.g. LinkedIn: Messages + Public Post) rather than a list discovered from the external account (Slack channels, Drive folders, calendars, …).

    When true, the connect/edit modal hides the per-connection "Sync new {channels}" affordance: there are no new channels to auto-enable, so the toggle would be meaningless. Leave false/omitted for connectors whose channel list grows as the user gains access to more channels — those still benefit from autoEnableNewChannelsByDefault and the toggle.

    hiddenChannels?: boolean

    Whether this connector's channels are internal detail rather than a user choice. When true, channels are still reported from getChannels() and mirrored so links can carry channel attribution (names shown on threads, compose targets), but the connect/edit modal renders no channel picker and enabling or disabling a channel is not a supported operation.

    Use for connectors whose sync scope is decided by rules rather than by channel selection — e.g. a chat connector that syncs direct messages plus anything that mentions you, wherever it happens.

    Distinct from fixedChannels, which still exposes a picker and only hides the "sync new {channels}" affordance.

    autoThreading?: boolean

    Whether this connector supports the platform's sequential auto-threading — folding a conversation that arrives as a run of separate top-level messages into a single thread. Set true for conversational connectors (chat, messaging) that mark eligible links with NewLink.autoThread. The UI shows a per-connection "Group related messages into conversations" toggle only for connectors that declare this.

    Leave undefined/false for connectors whose items are not conversational (calendars, issue trackers, file storage) — marking a link does nothing unless the connection both declares support and the user opted in.

    autoThreadingByDefault?: boolean

    Whether the per-connection auto-threading preference starts ON for newly added connections of this connector. Defaults to false (opt-in) — the least-surprise default, since a wrong fold is irreversible. Only meaningful when autoThreading is true. The user's explicit toggle always wins, and existing connections keep their stored preference.

    linkTypes?: LinkTypeConfig[]

    Registry of link types this connector creates (e.g., issue, event, message). Used for display in the UI (icons, labels, statuses).

    reactionCapabilities?: ReactionCapabilities

    Declares 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).

    dynamicLinkTypes?: boolean

    When true, this connector's effective link types are computed dynamically from its enabled channels' per-channel link types (each channel carries the link types for whatever product/resource it represents), rather than the static union of all declared providers' link types. Lets one connection surface different link types depending on what the user has enabled — e.g. a combined Google connection shows calendar/event link types (and thus the agenda) only when a calendar channel is enabled.

    Defaults to false (static link types — the behavior for every connector that doesn't set this). Requires the connector to attach per-channel linkTypes on the channels returned by getChannels.

    products?: ProductInfo[]

    Per-instance product metadata for combined (multi-product) connectors — one connection bundling several user-facing products under a single OAuth grant (e.g. the combined Google connector: Mail, Calendar, Tasks, Contacts).

    Each ProductInfo.scopeGroupId must match an OptionalScopeGroup.id declared in this connector's scopes so the API can derive per-product enablement from granted scopes + enabled channels.

    Leave undefined for plain (single-product) connectors — the API then omits the products/productStatus response fields and the app uses the standard per-connector flow.

    handleReplies?: boolean

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

    multipleInstances?: boolean

    When 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).

    class WorkflowTwist extends Twist<WorkflowTwist> {
    static readonly multipleInstances = true;
    // ...
    }
    userId: Uuid

    The user ID (twist_instance.owner_id) that installed this twist. Populated by the runtime before any lifecycle method runs.

    id: Uuid