Skip to content

Brokered integrations

OpenCloud separates the provider account a person authorizes from the bounded capabilities each app release requests. Google Workspace and reporting slots reuse encrypted Google connections, Asana slots bind an exact project, HubSpot CRM slots use an app-owned portal connection, and GoCardless Bank Account Data slots bind exact accounts selected during time-limited open-banking consent. Slack slots reuse one workspace installation and bind exact channels, while Telegram slots use one installation-managed bot and bind exact private chats or groups through one-time links. Apps receive typed actions and events, never provider tokens or a raw provider-request escape hatch.

AI is a built-in Function capability rather than a manifest slot. A person connects AI under Account → Integrations → AI using Codex/ChatGPT device authorization, Claude's browser-and-pasted-code login, or an OpenAI/Anthropic API key. The app owner then chooses App-provided AI or User-provided AI under App → Integrations. Function code keeps using ai.generateText, ai.streamText, ai.generateObject, or ai.generateImage; it never receives the provider credential or constructs a provider request. ai.streamText yields plain UTF-8 text chunks that a Function can return in a no-store response with X-Accel-Buffering: no to browser functions.stream without exposing provider events. The same app screen selects the text model and default reasoning effort.

yaml
integrations:
  drive:
    provider: google-drive
    account: calling_user
    cardinality: one
    capabilities:
      - drive.files.read
      - drive.files.write
  sheets:
    provider: google-sheets
    account: calling_user
    cardinality: one
    capabilities:
      - sheets.spreadsheets.read
      - sheets.spreadsheets.write

Slot names such as drive and sheets are app-defined. The manifest contains no email address, Google subject, provider resource ID, access token, or refresh token.

Providers and capabilities

ProviderCapabilitiesFunction client
asanaTasks, assignees, sections, custom fields, attachments, stories, and incoming eventsintegrations.asana(slot)
google-calendarcalendar.events.read, calendar.events.createintegrations.calendar(slot)
google-drivedrive.files.read, drive.files.writeintegrations.drive(slot)
google-sheetssheets.spreadsheets.read, sheets.spreadsheets.writeintegrations.sheets(slot)
google-docsdocs.documents.read, docs.documents.writeintegrations.docs(slot)
google-slidesslides.presentations.read, slides.presentations.writeintegrations.slides(slot)
google-analyticsanalytics.reports.readintegrations.use(slot)
google-search-consolesearch.performance.readintegrations.use(slot)
google-adsads.reporting.readintegrations.use(slot)
hubspot-crmcrm.contacts.*, crm.companies.*, crm.deals.*, owners, pipelines, notes, associationsintegrations.crm(slot)
gocardless-bank-account-databank.accounts.read, bank.balances.read, bank.transactions.readintegrations.bankAccountData(slot)
wise-balance-webhookpayments.received.reconcileintegrations.receivedPayments(slot)
slackslack.messages.send, slack.messages.receiveintegrations.slack(slot)
telegramtelegram.messages.send, telegram.messages.receiveintegrations.telegram(slot)

Capabilities under the wrong provider are rejected during manifest validation. Write capability does not implicitly grant an app read operations, even when the underlying Google OAuth scope technically permits both.

integrations.use(slot) is the provider-independent client. It exposes bindings() and call(operation, input, options). The SDK validates a bounded operation identifier and sends it to the integration broker; the broker—not the Function—validates the slot's provider, capability, binding, and input schema. New integration operations therefore do not require a new core SDK release. Existing provider-specific clients remain available for compatibility.

AI connection modes

  • App-provided AI uses the app owner's explicitly selected AI connection. It can serve authenticated, anonymous, scheduled, inbound-email, and other system invocations. The owner bears the provider usage for those calls.
  • User-provided AI resolves the authenticated caller's default AI connection. Each caller bears their own provider usage and must explicitly allow the exact app before its first AI request. A caller-less invocation fails closed because no user connection can be selected.

For User-provided AI, open the stable same-origin /_opencloud/ai-consent?return_to=<encoded same-origin path> route as a popup directly from the user's Generate or Chat action. The route moves to the trusted Auth origin, names the app, explains that the user's tokens will be used, and offers Allow AI access or Don't allow. Wait for the opencloud:ai-consent message from that exact popup before calling the Function. Do not fetch or submit the permission route, embed it in a frame, or recreate its UI inside the app. The AI gateway independently rejects the call until the signed-in user has an active app-specific ai.inference grant, so frontend behavior is not the security boundary.

Both modes accept a connected OpenAI Codex/ChatGPT device login, Claude pasted-code login, OpenAI API key, or Anthropic API key. Credentials are encrypted at rest, materialized only into one short-lived inference runtime, removed before the model turn, and never enter Function environment variables, logs, source bundles, or durable AI request records. Hosted browser OAuth is not an AI gateway credential.

The app-selected text model is authoritative and fixes whether the call uses OpenAI or Anthropic. The selected connection must match that provider. The selected reasoning effort is the default; a Function may request a different effort for one generation. Image generation retains its dedicated OpenAI model and requires an eligible OpenAI connection.

Account ownership and cardinality

  • account: app resolves bindings configured by the app owner. It suits a booking site, reporting job, or public workflow that writes to an owner-controlled Workspace account.
  • account: calling_user resolves bindings for the signed-in Function caller. It suits a product in which every user works with their own Google account.

A system or scheduled Function can use account: app without a separate background permission. A caller-less invocation cannot resolve account: calling_user because there is no user whose binding should be used.

cardinality: one retains one binding per slot and owner/caller. Rebinding replaces it. cardinality: many retains several accounts or Calendar resources; call bindings() and pass the returned opaque bindingId to an operation.

Calendar slots bind an exact calendar. Asana slots bind an exact project. Drive, Sheets, Docs, and Slides slots bind the connected Google account, then accept bounded provider resource IDs as method inputs. Connecting two provider accounts creates two independently encrypted credential records and two possible bindings. Tokens never enter an app bundle or Function invocation.

Google Analytics, Search Console, and Ads slots always use account: app. They bind one exact GA4 property, verified Search Console site, or directly accessible non-manager Ads customer. The resource is selected by the app owner and is never accepted as a free-form Function input.

Bank Account Data slots bind an exact account returned by an authorized bank. cardinality: many can bind several accounts from one or more bank consents; the Function selects among them with the same opaque bindingId pattern.

Slack slots always use account: app and bind exact channels where @OpenCloud is already a member. One reusable workspace connection can serve several apps owned by that OpenCloud account. A workspace installation has one managing OpenCloud account. A channel can have several send-only app bindings, but only one receive-capable app binding, so an incoming conversation is never dispatched to several apps implicitly.

Telegram slots also use account: app. A one-time, app-scoped deep link adds the exact private chat or group opened by the human and initially binds it to that app. The chat then remains a reusable account connection that the owner can select for another app slot without pairing it again; an app never supplies a raw Telegram chat ID. cardinality: one selects one chat at a time, while many can bind several. One OpenCloud account manages a chat connection. The same chat may have several send-only app bindings for that account, but only one receive-capable app binding.

HubSpot CRM slots always use account: app. The app owner authorizes a portal once and can bind that reusable connection to specific owned apps. A Function sees only an opaque binding and CRM record IDs. HubSpot access and refresh tokens stay encrypted in OpenCloud and are refreshed by the broker.

Function API

ts
import { defineFunction, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({
    spreadsheetId: schema.string({ minLength: 1, maxLength: 1_024 }),
  }),
  async handler({ input, integrations }) {
    const files = await integrations.drive("drive").listFiles({
      pageSize: 25,
      nameContains: "report",
    });
    const values = await integrations.sheets("sheets").getValues({
      spreadsheetId: input.spreadsheetId,
      range: "Sheet1!A1:D20",
    });
    return { files, values };
  },
});

The bounded methods are:

  • Calendar: bindings, listEvents, createEvent;
  • Drive: bindings, listFiles, getFile, createFile, createFolder;
  • Sheets: bindings, getSpreadsheet, createSpreadsheet, getValues, updateValues, appendValues;
  • Docs: bindings, getDocument, createDocument, appendText;
  • Slides: bindings, getPresentation, createPresentation, addSlide.
  • Provider-independent integrations: bindings, call.
  • Asana: bindings, listTasks, getTask, createTask, updateTask, listAssignees, assignTask, listSections, moveTask, listCustomFields, updateCustomFields, listAttachments, createExternalAttachment, uploadAttachment, downloadAttachment, listStories, createComment.
  • Bank Account Data: bindings, account, balances, listTransactions.
  • Wise received payments: bindings, reconcile.
  • Slack: bindings, sendMessage.
  • Telegram: bindings, sendMessage.
  • HubSpot CRM: bindings; contacts, companies, and deals each provide get, search, create, update, and upsert; owners.list; pipelines.listDeals; notes.create; and associations.create.

Google reporting

Google reporting requires runtime SDK 2.2.0 or later, is read-only, and is app-owned. Declare separate slots so each one has an independently reviewable capability and exact resource binding:

yaml
integrations:
  site_analytics:
    provider: google-analytics
    account: app
    cardinality: one
    capabilities: [analytics.reports.read]
  site_search:
    provider: google-search-console
    account: app
    cardinality: one
    capabilities: [search.performance.read]
  paid_search:
    provider: google-ads
    account: app
    cardinality: one
    capabilities: [ads.reporting.read]

Function code uses allowlisted dimensions and metrics. It cannot submit raw Google Analytics requests, Search Console filters, GAQL, customer IDs, or provider tokens:

ts
const analytics = await integrations.use("site_analytics").call(
  "analytics.reports.run",
  {
    startDate: "2026-08-01",
    endDate: "2026-08-31",
    dimensions: ["date", "country"],
    metrics: ["activeUsers", "sessions", "screenPageViews"],
    limit: 250,
  },
);
const search = await integrations
  .use("site_search")
  .call("searchConsole.performance.query", {
    startDate: "2026-08-01",
    endDate: "2026-08-31",
    dimensions: ["query", "page"],
    type: "web",
    rowLimit: 250,
  });
const ads = await integrations.use("paid_search").call(
  "googleAds.campaigns.report",
  {
    startDate: "2026-08-01",
    endDate: "2026-08-31",
    limit: 250,
  },
);

Ranges are limited to 366 days. Analytics and Search Console return at most 1,000 rows; Ads returns at most 500 campaigns. Development returns stable synthetic reports and never contacts Google.

The first Ads surface intentionally excludes manager-account hierarchy, mutations, arbitrary GAQL, conversion uploads, and billing operations.

HubSpot CRM

Declare only the record families and actions the app needs. HubSpot connections are app-owned because one OAuth installation authorizes an entire portal.

yaml
integrations:
  sales_crm:
    provider: hubspot-crm
    account: app
    cardinality: one
    capabilities:
      - crm.contacts.read
      - crm.contacts.write
      - crm.notes.write
      - crm.associations.write
ts
import { defineFunction, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({
    email: schema.string({ minLength: 3, maxLength: 320 }),
    note: schema.string({ minLength: 1, maxLength: 65_536 }),
  }),
  async handler({ input, integrations }) {
    const crm = integrations.crm("sales_crm");
    const matches = await crm.contacts.search({
      filterGroups: [{
        filters: [{ propertyName: "email", operator: "EQ", value: input.email }],
      }],
      properties: ["email", "firstname", "lastname"],
      limit: 1,
    });
    const contact = matches.records[0] ?? await crm.contacts.create({
      properties: { email: input.email },
    });
    const note = await crm.notes.create({ body: input.note });
    await crm.associations.create({
      fromObjectType: "notes",
      fromRecordId: note.id,
      toObjectType: "contacts",
      toRecordId: contact.id,
    });
    return { contact, note };
  },
});

The available capabilities are crm.contacts.read|write, crm.companies.read|write, crm.deals.read|write, crm.owners.read, crm.pipelines.read, crm.notes.write, and crm.associations.write. An association call also requires write capabilities for both record types; a note endpoint requires crm.notes.write. Write permission does not implicitly let app code run a read operation. Upsert requires an internal property marked unique in HubSpot.

The MVP intentionally has no delete/archive method, raw API method, arbitrary object type, custom association label, webhook/event trigger, or account: calling_user mode. Development calls return deterministic synthetic contacts, companies, deals, owners, pipelines, notes, and associations without contacting HubSpot.

HubSpot installation setup

  1. Create a HubSpot public app with OAuth enabled.
  2. Register exactly https://<base-domain>/v1/integrations/hubspot/callback as its redirect URL.
  3. Keep HubSpot's basic oauth scope required/default, and configure the CRM scopes the installation will offer as conditionally required: crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.companies.read, crm.objects.companies.write, crm.objects.deals.read, crm.objects.deals.write, and crm.objects.owners.read. OpenCloud puts only the scopes required by the selected app slot in the install URL. Notes use contact-write, deal pipelines use deal-read, and default associations add no separate HubSpot OAuth scope.
  4. Set HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET together in the OpenCloud control-plane environment, then restart the API and workers.
  5. The app owner opens Account → Integrations → HubSpot CRM, authorizes a portal, then selects it under App → Integrations for each declared slot.

OAuth access tokens are short-lived and refreshed server-side. Disconnecting a portal revokes its refresh token when HubSpot is reachable, deletes the local encrypted credentials, and removes its app bindings.

Slack channels

Declare sending alone, or pair receiving with one declared system Function:

yaml
functions:
  - name: receive-slack-message
    entrypoint: functions/receive-slack-message/index.ts
    access: system
integrations:
  team_chat:
    provider: slack
    account: app
    cardinality: many
    capabilities:
      - slack.messages.send
      - slack.messages.receive
    events:
      message:
        function: receive-slack-message

The app owner connects a workspace under Account → Integrations → Slack, invites @OpenCloud to the intended channels, and creates exact channel bindings under App → Integrations. Each receive-capable binding selects one trigger mode:

  • mention invokes the Function only when the message tags @OpenCloud;
  • all_messages invokes the Function for every human message in the channel.

Bot, hidden, and Slack system/change events are ignored; human text, file-share, /me, and thread-broadcast messages can trigger all_messages. Incoming text is untrusted external input. The Function receives a normalized OpenCloudSlackMessageEvent, including the opaque binding ID and the Slack message/thread timestamps. It can reply in the same thread without seeing a bot token:

ts
import {
  defineFunction,
  schema,
  type OpenCloudSlackMessageEvent,
} from "@opencloud/server";

const slackMessageInput = schema.object({
  source: schema.literal("opencloud.slack"),
  type: schema.literal("slack.message.received"),
  version: schema.literal(1),
  id: schema.uuid(),
  channel: schema.object({ label: schema.string() }),
  sender: schema.object({ id: schema.string() }),
  message: schema.object({
    text: schema.string({ maxLength: 40_000 }),
    timestamp: schema.string(),
    threadTimestamp: schema.nullable(schema.string()),
    mentionedOpenCloud: schema.boolean(),
  }),
  binding: schema.object({
    id: schema.uuid(),
    integrationName: schema.string(),
    triggerMode: schema.enum(["mention", "all_messages"]),
  }),
  receivedAt: schema.string(),
});

export default defineFunction({
  input: slackMessageInput,
  async handler({ input, integrations }) {
    const event: OpenCloudSlackMessageEvent = input;
    await integrations.slack("team_chat").sendMessage({
      bindingId: event.binding.id,
      threadTimestamp: event.message.threadTimestamp ?? event.message.timestamp,
      text: `Received: ${event.message.text}`,
    });
    return { accepted: true };
  },
});

Delivery is at least once. Use the stable event id to make side effects idempotent; a Function can be retried if it times out or fails after receiving the event.

Every outbound message begins with App: <OpenCloud app name>, while Slack shows @OpenCloud as the sender. Apps cannot choose another bot identity or access an arbitrary workspace/channel. Development Function sends return a deterministic synthetic receipt and do not contact Slack; live incoming Slack events target only active production deployments.

Slack installation setup

An OpenCloud operator creates one Slack app for the installation, names its bot OpenCloud, and configures:

  1. workspace-level installation and distribution to the intended workspaces (organization-wide Enterprise Grid installation is not part of this surface);
  2. OAuth redirect URL https://<base-domain>/v1/integrations/slack/callback;
  3. HTTP Event Subscriptions enabled, Socket Mode disabled, and the verified Events Request URL https://<base-domain>/v1/integrations/slack/events;
  4. the available bot scopes channels:read, groups:read, chat:write, app_mentions:read, channels:history, and groups:history;
  5. Subscribe to bot events entries app_mention, message.channels, and message.groups, followed by Save Changes;
  6. token rotation when the Slack app is intended to use expiring tokens;
  7. SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, and SLACK_SIGNING_SECRET together in the control-plane environment.

SLACK_MAX_INBOUND_PER_APP_HOUR defaults to 1,000 Function triggers, and SLACK_EVENT_RETENTION_DAYS defaults to 30 days.

OpenCloud derives the least bot scopes from the app slot: chat:write for sending; app_mentions:read, channels:history, and groups:history for receiving; and channels:read plus groups:read to let the owner select joined channels. Slash commands and direct-message bindings are not part of this surface.

Telegram chats

Declare sending alone, or pair receiving with one declared system Function:

yaml
functions:
  - name: receive-telegram-message
    entrypoint: functions/receive-telegram-message/index.ts
    access: system
integrations:
  team_chat:
    provider: telegram
    account: app
    cardinality: many
    capabilities:
      - telegram.messages.send
      - telegram.messages.receive
    events:
      message:
        function: receive-telegram-message

The app owner opens App → Integrations → team_chat, selects any previously paired chat, chooses the inbound mode, and saves the selection. Add private chat and Add group chat create one-time pairing links for additional chats. A link expires after 10 minutes and initially binds only the exact slot for which it was created. Adding a private chat opens the bot directly. Adding a group asks the human to select it; only a Telegram administrator of that group can finish the pairing.

Private chats deliver every human text message after pairing. Groups offer two modes:

  • directed invokes the Function for a command explicitly addressed to the bot (for example /opencloud@OpenCloudBot) or a reply to the bot. Telegram may also deliver an unqualified /opencloud command when this bot is the command's current recipient. The bot remains an ordinary group member.
  • all_messages invokes the Function for every human text message. Its group deep link asks Telegram to grant the bot only the basic Manage Chat administrator right in that exact group. This is necessary while BotFather privacy mode remains enabled; it is not needed for private chats or directed groups.

Telegram channels, edited messages, media-only messages, bot-authored messages, and Telegram service updates do not invoke app Functions. Group and supergroup migrations preserve the binding. Removing the bot, or demoting it below the access required by an all_messages binding, marks the chat for reconnection.

Incoming text is untrusted external input. The Function receives a normalized OpenCloudTelegramMessageEvent with an opaque binding ID, the chat kind, Telegram message/topic IDs, and an opaque Telegram sender ID. It can reply in the same topic or to the exact incoming message without seeing the bot token or a raw chat ID:

ts
import {
  defineFunction,
  schema,
  type OpenCloudTelegramMessageEvent,
} from "@opencloud/server";

const telegramMessageInput = schema.object({
  source: schema.literal("opencloud.telegram"),
  type: schema.literal("telegram.message.received"),
  version: schema.literal(1),
  id: schema.uuid(),
  chat: schema.object({
    label: schema.string(),
    type: schema.enum(["private", "group", "supergroup"]),
  }),
  sender: schema.object({ id: schema.string() }),
  message: schema.object({
    text: schema.string({ maxLength: 16_384 }),
    messageId: schema.number({ integer: true, min: 0 }),
    messageThreadId: schema.nullable(schema.number({ integer: true, min: 1 })),
    replyToMessageId: schema.nullable(schema.number({ integer: true, min: 0 })),
    directedAtOpenCloud: schema.boolean(),
    sentAt: schema.string(),
  }),
  binding: schema.object({
    id: schema.uuid(),
    integrationName: schema.string(),
    triggerMode: schema.enum(["directed", "all_messages"]),
  }),
  receivedAt: schema.string(),
});

export default defineFunction({
  input: telegramMessageInput,
  async handler({ input, integrations }) {
    const event: OpenCloudTelegramMessageEvent = input;
    await integrations.telegram("team_chat").sendMessage({
      bindingId: event.binding.id,
      text: `Received: ${event.message.text}`,
      ...(event.message.messageThreadId
        ? { messageThreadId: event.message.messageThreadId }
        : {}),
      replyToMessageId: event.message.messageId,
    });
    return { accepted: true };
  },
});

Delivery is at least once. Use the stable event id to make side effects idempotent. Every outbound message begins with App: <OpenCloud app name>. Development sends return a deterministic synthetic receipt and do not contact Telegram; live inbound events target only the currently active production deployment.

Telegram installation setup

An OpenCloud operator creates one bot for the installation through BotFather:

  1. run /newbot, use OpenCloud as the display name, and choose an available username; BotFather normally requires a bot suffix, such as OpenCloudBot, unless the bot has an eligible collectible username;
  2. run /setjoingroups for that bot and keep group joining Enabled;
  3. run /setprivacy for that bot and keep privacy mode Enabled;
  4. copy the Bot API token, and generate a separate random webhook secret using characters from A-Z, a-z, 0-9, _, and - (for example, openssl rand -hex 32);
  5. configure TELEGRAM_BOT_TOKEN, TELEGRAM_BOT_USERNAME without the leading @, and TELEGRAM_WEBHOOK_SECRET together in the control-plane environment.

On startup, OpenCloud verifies that the token belongs to the configured username, group joining is enabled, and privacy mode is still enabled, then registers https://<base-domain>/v1/integrations/telegram/events with Telegram using the secret header and only the message and my_chat_member update types. The endpoint must be publicly reachable over HTTPS. Do not disable privacy mode or manually configure a second webhook.

TELEGRAM_MAX_INBOUND_PER_APP_HOUR defaults to 1,000 Function triggers, and TELEGRAM_EVENT_RETENTION_DAYS defaults to 30 days.

Asana

An Asana slot is always contained to one project selected by a human under App → Integrations. OpenCloud verifies that a task belongs to that project before every task-specific read or write. It similarly accepts only sections and custom fields configured on that project. In Asana, a section is a named group within a project: typically a heading in list view or a column in board view.

Declare only the operations the app needs:

yaml
functions:
  - name: receive-asana-events
    entrypoint: functions/receive-asana-events/index.ts
    access: system
integrations:
  work:
    provider: asana
    account: app
    cardinality: one
    capabilities:
      - asana.tasks.read
      - asana.tasks.create
      - asana.tasks.update
      - asana.assignees.read
      - asana.assignees.write
      - asana.sections.read
      - asana.sections.move_tasks
      - asana.custom_fields.read
      - asana.custom_field_values.write
      - asana.attachments.read
      - asana.attachments.write
      - asana.stories.read
      - asana.comments.write
      - asana.events.receive
    events:
      function: receive-asana-events

asana.events.receive additionally requires asana.tasks.read, account: app, and a declared system Function. An Asana slot without incoming events may instead use account: calling_user. The capability groups are checked independently. Because mutation methods return normalized current task state, task creation, updates, assignment, section moves, and custom-field writes also require asana.tasks.read. For example, creating an already-assigned task needs asana.tasks.read, asana.tasks.create, and asana.assignees.write.

Function code uses the typed client:

ts
const asana = integrations.asana("work");
const { tasks, nextOffset } = await asana.listTasks({ limit: 50 });
const created = await asana.createTask({
  name: "Prepare launch brief",
  notes: "Created by OpenCloud",
  assigneeId,
  sectionId,
  customFields: {
    [priorityFieldId]: priorityOptionId,
  },
});
await asana.uploadAttachment({
  taskId: created.id,
  data: reportBytes,
  name: "launch-brief.pdf",
  contentType: "application/pdf",
});

Task pages return an opaque nextOffset. Attachment uploads and downloads are limited to 100 MiB. createExternalAttachment attaches an HTTPS link without copying its bytes. Custom-field values use the field and enum-option IDs returned by listCustomFields; an app never supplies a raw provider request. Assignee IDs come from listAssignees and are verified against the selected project's workspace before assignment. listStories exposes the task's activity/history stream, including comments and system stories, while createComment adds only a comment. Treat all task, user, attachment, and story text as untrusted external content; do not inject returned HTML directly into the frontend.

For incoming changes, OpenCloud creates a project webhook, verifies Asana's HMAC signature over the exact body, deduplicates delivery bodies, fetches the current contained task state, and invokes the declared Function. A bounded modified_since scan runs every ten minutes to recover changes a webhook may have missed. Its input is an OpenCloudAsanaEvent:

ts
import {
  defineFunction,
  schema,
  type OpenCloudAsanaEvent,
} from "@opencloud/server";

export default defineFunction({
  input: schema.record(),
  handler: async ({ input, data }) => {
    const event = input as unknown as OpenCloudAsanaEvent;
    if (
      event.source !== "opencloud.asana" ||
      event.type !== "asana.events"
    ) {
      throw new Error("Unexpected event");
    }

    await data.table("asana_deliveries").create({
      id: event.id,
      source: event.delivery.source,
      task_count: event.tasks.length,
    });
    return { accepted: true };
  },
});

event.delivery.source is webhook or reconciliation. Each normalized event includes its action, resource, optional parent and change, actor ID, and occurrence time. tasks pairs affected task IDs with current normalized task state; task is null when the task was removed from the project or is no longer readable. Task memberships are reduced to the bound project; assignee, section, and custom-field details appear only when their read capabilities are declared, and custom fields are reduced to fields configured on that project. Each delivery includes at most 20 current task snapshots; tasksTruncated is true when additional affected task IDs remain available in events. Treat event.id as an idempotency key. Changes made by the app can arrive back through the same webhook, so handlers should avoid writing fields that already have the desired value. Processed and failed delivery payloads are retained for 30 days and then removed.

A human first connects an Asana account under Account → Integrations → Asana, then selects the exact project for each slot under App → Integrations. Development Functions use deterministic synthetic Asana data and never contact Asana. The initial surface intentionally omits task and attachment deletion, project/section/custom-field creation, project membership changes, and arbitrary Asana API access.

Asana installation setup

An OpenCloud operator creates one OAuth app in the Asana developer console:

  1. register https://<base-domain>/v1/integrations/asana/callback as an exact redirect URL;
  2. select Full Permissions and configure the app's allowed workspace or distribution settings;
  3. configure ASANA_CLIENT_ID and ASANA_CLIENT_SECRET together in the control-plane environment.

Full Permissions is currently required because Asana does not expose granular OAuth scopes for every endpoint used here, including project sections. OpenCloud therefore requests Asana's default authorization scope, then independently enforces the manifest capability, binding grants, caller, release, and exact-project boundary for every operation. The OAuth client secret and encrypted user tokens never enter an app or Function.

Wise transactions

Wise payment reconciliation uses an Account deposit events notification that a regular Wise user creates in the Wise website. It does not require a Wise API token, an OpenCloud Wise OAuth application, or mTLS. Declare one app-owned slot:

yaml
integrations:
  payments:
    provider: wise-balance-webhook
    account: app
    cardinality: one
    capabilities:
      - payments.received.reconcile

The app registers an expected credit by repeatedly reconciling the same stable payment key. A retry with different details is rejected:

ts
const payment = await integrations.receivedPayments("payments").reconcile({
  paymentKey: order.id,
  reference: order.reference,
  amount: order.amount,
  currency: order.currency,
  receivedAfter: order.createdAt,
  receivedBefore: order.expiresAt,
});

The result status is pending, matched, or ambiguous. Only a unique, unclaimed credit with the exact amount, currency, and time window is matched. When Wise includes a transfer reference, it can disambiguate a credit. The legacy Account deposit event may omit that reference, so equal simultaneous payments require manual review rather than automatic fulfillment.

The owner creates a notification URL under Account → Integrations → Wise transactions, then creates exactly one Wise webhook for Account deposit events and sends Wise's test notification. They should not select Transfer update events or Transfer issue events. OpenCloud verifies Wise's RSA/SHA-256 signature over the exact request bytes. A test activates the connection but never creates a credit; retries are deduplicated. Apps receive only bounded reconciliation results, not the owner's Wise balance or transaction feed.

The broker returns normalized records and plain text rather than raw provider responses. Drive file creation accepts at most 1,000,000 UTF-8 characters; Sheets writes accept at most 10,000 bounded cells; Docs and Slides text writes are also bounded. This first surface deliberately omits delete/trash, permissions and sharing, arbitrary Google query or batchUpdate bodies, and unbounded binary download/upload.

Bank Account Data

Declare only the read capabilities the app actually uses:

yaml
integrations:
  bank:
    provider: gocardless-bank-account-data
    account: calling_user
    cardinality: many
    capabilities:
      - bank.accounts.read
      - bank.balances.read
      - bank.transactions.read

The typed client returns normalized, bounded records rather than raw provider responses:

ts
import { defineFunction, schema } from "@opencloud/server";

export default defineFunction({
  input: schema.object({
    bindingId: schema.optional(schema.string({ minLength: 36, maxLength: 36 })),
    dateFrom: schema.string({ minLength: 10, maxLength: 10 }),
    dateTo: schema.string({ minLength: 10, maxLength: 10 }),
  }),
  async handler({ input, integrations }) {
    const bank = integrations.bankAccountData("bank");
    const [account, balances, transactions] = await Promise.all([
      bank.account({ bindingId: input.bindingId }),
      bank.balances({ bindingId: input.bindingId }),
      bank.listTransactions({
        bindingId: input.bindingId,
        dateFrom: input.dateFrom,
        dateTo: input.dateTo,
        limit: 100,
        includePending: true,
      }),
    ]);
    return { account, balances, transactions };
  },
});

Transaction ranges are limited to 366 days per call and results to 500 transactions. The connected institution can expose less history, and its own rate limits still apply. Cache or persist the minimum app data needed instead of polling on every page render.

An operator configures GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY together on the control plane. These are Bank Account Data credentials and never enter an app. A GoCardless Billing read/write access token is a separate product credential and is deliberately not accepted by this integration.

The human flow is:

  1. select a country and institution in Account → Integrations → Bank Account Data;
  2. continue to the institution-hosted login and approve the exact read scopes;
  3. return to OpenCloud, which validates the state-bound requisition and stores only encrypted requisition/agreement identifiers;
  4. bind one or more exact accounts in App → Integrations;
  5. renew consent when the institution's access period ends, or disconnect to remove every local binding and delete the provider requisition when possible.

OpenCloud never receives the person's bank username, password, or MFA answer. Development Functions receive deterministic synthetic account, balance, and transaction records and do not contact GoCardless or a bank.

Google Workspace installation setup

An OpenCloud operator creates one Google Cloud OAuth web client for the installation:

  1. enable the Calendar, Drive, Sheets, Docs, Slides, Google Analytics Admin, Google Analytics Data, Search Console, and Google Ads APIs;
  2. configure the OAuth consent screen and add the scopes used by the intended capabilities;
  3. register the canonical callback https://<base-domain>/v1/integrations/google-workspace/callback and retain https://<base-domain>/v1/integrations/google-calendar/callback as an authorized compatibility URL;
  4. configure GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET together in the control-plane environment;
  5. for Ads, obtain an approved developer token through a Google Ads manager account and configure GOOGLE_ADS_DEVELOPER_TOKEN in the control-plane environment; the hosted installation accepts Infisical's existing GOOGLE_ADS_MANAGEMENT_TOKEN as the source alias;
  6. complete Google's testing, publishing, verification, and security-review requirements before broad external use.

Current service mappings request https://www.googleapis.com/auth/spreadsheets, https://www.googleapis.com/auth/documents, https://www.googleapis.com/auth/presentations, and https://www.googleapis.com/auth/drive for their respective read or write capabilities. Calendar keeps its narrower event scopes. The broad Drive scope is a restricted scope and can require verification plus a security assessment for externally published apps. Prefer a future drive.file plus Google Picker mode when the product only needs files explicitly selected or created through OpenCloud.

Reporting capabilities request https://www.googleapis.com/auth/analytics.readonly, https://www.googleapis.com/auth/webmasters.readonly, or https://www.googleapis.com/auth/adwords incrementally. The Ads developer token stays installation-side and is never stored in an app binding.

The OAuth client secret identifies the OpenCloud installation. It is distinct from every user's tokens and is never supplied to an app or Function.

Google Workspace connection and credential lifecycle

  1. A person selects Connect Google in Account → Integrations → Google or follows the safe connectUrl returned for an unbound calling-user integration.
  2. OpenCloud starts a state-bound, PKCE-protected OAuth web-server flow, binds completion to the initiating signed-in OpenCloud user, requests offline access, and uses incremental authorization.
  3. The callback exchanges the code server-side. Access and refresh tokens are encrypted with authenticated, identity-bound encryption before storage.
  4. The account becomes a reusable connection. A person binds it in App → Integrations; Calendar, Analytics, Search Console, and Ads also require an exact resource choice.
  5. Each Function call carries a short-lived signed grant for the exact app release, Function, environment, and caller. The broker rechecks the manifest capability and stored binding, refreshes an expiring access token, calls the selected Google API, records an audit outcome, and returns normalized data.
  6. Reconnect uses the same account fingerprint and updates the encrypted token set. Disconnect revokes the Google grant when possible, removes encrypted credentials and bindings, and prevents further Function access.

One consent can therefore be reused by many apps. A later app that needs a scope not yet granted causes one incremental reconnect; it does not create a separate OAuth credential per app.

Development Functions use deterministic synthetic Workspace and reporting data and never contact Google. Production requires an active release and a deliberate human-created binding.

Agent and CLI discovery

MCP builders request the integrations capability from get_app_starter. The returned checklist, hosted skill, manifest reference, compatible public CLI release, and @opencloud/server declarations expose the same provider names, capabilities, and typed clients. This includes Google reporting, Asana, HubSpot CRM, Bank Account Data, Slack's normalized messages, and Telegram's pairing and message contract. Agents can declare and use those bounded surfaces, but they cannot connect an account, pair a chat, select a binding, or read credentials; those remain human actions in account Integrations and App Integrations.

Safety boundaries

Manifest capabilities, stored binding grants, caller identity, provider type, and provider scope coverage are enforced independently. A public app with an owner-bound provider account must still validate what visitors may request and what data its Function may return. Connecting Google or a bank does not make an anonymous request trustworthy. Bank data is sensitive personal data: do not log it, expose it to unrelated callers, or retain more than the app needs. Slack and Telegram messages are also untrusted, provider-originated input: validate commands and identifiers, bound any retained content, and do not treat a tag, reply, sender ID, or possession of a chat as proof that a person is authorized for a sensitive app action.

See Google's OAuth scope catalog, OAuth web-server flow, and Drive scope guidance. For Asana, see its OAuth guide, OAuth scopes, and webhook reference. For open banking, see the GoCardless Bank Account Data overview and quick start. For Slack, see Installing with OAuth, the Events API, and request signing. For Telegram, see Bot features and privacy mode, bot deep links, and the Bot API webhook contract.

Self-hosted infrastructure for agent-built applications.