Organisation-built integrations
An organisation can build its own integrations and share them with its other apps. An integration is an ordinary OpenCloud app whose schema-3 manifest publishes a contract under provides.integration. Other apps in the same organisation declare a slot for it, a person connects an account once, and Functions call its operations without ever seeing the credentials. A provider can also sync data in the background for each connection, receive webhooks, and deliver events to the apps that use it.
Use one when several apps need the same system — an internal ERP, a partner API, a warehouse database gateway — or when credentials should be managed by one owner instead of being copied into each app's secrets.
| Concept | What it is |
|---|---|
| Provider app | The app that publishes the integration and implements each operation in a system Function. |
| Contract | The active release's provides.integration: name, authorization, capabilities, operations, and optional sync, webhook, and events. |
| Connection | Write-only credential values or OAuth tokens for one provider app, held personally or by the organisation. |
| Binding | A consumer app slot connected to one connection, limited to the capabilities both sides declare. |
| Event | A declared change the provider emits for a connection, delivered at least once to subscribed slots. |
Integrations are shared only inside the organisation that owns the provider app. Cross-organisation sharing is not available.
Publish an integration
Declare the contract in the provider app's opencloud.yaml. Every operation targets a declared system Function and includes a static fake output that consumer apps receive in development.
schemaVersion: 3
appId: 6f9619ff-8b86-4e6e-a62a-889950f42d3e
frontend:
directory: frontend
runtime:
sdk:
version: 2.5.0
functions:
- name: orders-list
entrypoint: functions/orders-list/index.ts
access: system
provides:
integration:
name: acme-erp
title: Acme ERP
description: Orders and customers from the Acme ERP.
credentials:
- name: ERP_API_KEY
label: API key
- name: ERP_BASE_URL
label: Base URL
secret: false
capabilities:
- name: orders.read
description: Read orders
operations:
- name: orders.list
capability: orders.read
function: orders-list
description: List orders, optionally filtered by status.
input:
type: object
properties:
status: { type: string, enum: [open, shipped] }
fake:
orders:
- { id: ord_1001, status: open, total: "120.00" }nameis lowercase kebab-case and must be unique in the organisation. Validation and deployment reject a name another active app already publishes.credentialsare the fields a person enters when connecting (at most 20). Names follow secret naming rules and must differ from the app's ownsecrets. Fields are secret and required unlesssecret: falseoroptional: true.capabilitiesgroup operations; consumer slots request capabilities, never individual operations.operationsuse dotted camelCase names such asorders.list. Each needs a description, a declared capability, asystemFunction, and afakeoutput of at most 16 KiB.inputandoutputare optional JSON Schema documents for consumers and agents; the Function's owndefineFunctioninput schema remains authoritative.
The operation Function receives the consumer's input as its input and reads the connection's credential values with secrets.get(NAME):
import { defineFunction, errors, schema } from "@opencloud/server";
export default defineFunction({
input: schema.object({ status: schema.optional(schema.string()) }),
handler: async ({ input, secrets }) => {
const response = await fetch(`${secrets.get("ERP_BASE_URL")}/orders`, {
headers: { authorization: `Bearer ${secrets.get("ERP_API_KEY")}` },
});
if (response.status === 401) {
throw errors.unauthorized("ERP_KEY_REJECTED", "Acme ERP rejected the API key");
}
if (response.status === 404) {
throw errors.notFound("ORDERS_NOT_FOUND", "No orders were found");
}
if (!response.ok) throw errors.unavailable("ERP_UNAVAILABLE", "Acme ERP is unavailable");
return { orders: await response.json() };
},
});Credential values exist only for that brokered invocation. They are never stored in the provider app's environment, logs, or database, and the provider cannot enumerate connections. Typed 4xx errors such as ORDERS_NOT_FOUND reach the calling app unchanged; other failures become INTEGRATION_PROVIDER_FAILED or a retryable INTEGRATION_PROVIDER_UNAVAILABLE. Responses are limited to 1 MiB and 25 seconds.
Throw errors.unauthorized (401) only when the external system rejects the connection's credentials or token. OpenCloud then marks the connection Reconnect required, and calls fail with INTEGRATION_RECONNECT_REQUIRED until someone who manages it replaces the credentials or signs in again. A sync Function's 401 does the same.
The provider app's normal Use audience decides who may use the integration, and its Administer audience decides who may change it. Organisation-wide Use is the usual choice.
Sign in with OAuth
When the external system uses OAuth 2.0, declare authorization instead of asking people to paste tokens. OpenCloud runs the sign-in: it creates the state, the PKCE challenge, and the callback, and keeps the tokens write-only. The provider app's own system Functions exchange the code and refresh tokens with the client secret, so OpenCloud never calls a token endpoint an app declares.
secrets:
CRM_CLIENT_SECRET: required
provides:
integration:
name: acme-crm
title: Acme CRM
description: Contacts from Acme CRM.
authorization:
type: oauth2
authorizationUrl: https://login.acme.example/oauth/authorize
clientId: opencloud-acme
scopes: [contacts.read]
exchange: oauth-exchange
refresh: oauth-refresh
capabilities:
- name: contacts.read
description: Read contacts
operations:
- name: contacts.list
capability: contacts.read
function: contacts-list
description: List contacts.
fake: { contacts: [{ id: c_1, name: Ada Lovelace }] }Register https://<your OpenCloud domain>/v1/custom-integrations/oauth/callback as the redirect URI with the provider; the integration's page shows the exact value to its administrators. pkce defaults to true.
- The exchange Function receives
{ code, redirectUri, codeVerifier }and returns{ accessToken, refreshToken?, expiresIn? }(seconds). - The optional refresh Function receives
{ refreshToken }and returns the same shape; OpenCloud keeps the previous refresh token when none is returned. OpenCloud calls it, once at a time per connection, when a request needs an access token that expires within a minute. - Operation, sync, and webhook Functions read the access token with
secrets.get("OAUTH_ACCESS_TOKEN"), or the name set inauthorization.accessToken. Exchange and refresh Functions never receive it. - A rejected refresh, or an operation or sync Function's 401, marks the connection Reconnect required. Calls then fail with
INTEGRATION_RECONNECT_REQUIREDand adetails.connectUrluntil someone who manages the connection signs in again.
Credential fields may still be declared alongside OAuth, for example an account subdomain; people enter them before signing in.
Sync data for each connection
A provider can copy data from the external system in the background. Declare a sync Function with a five-field cron schedule and an optional IANA timezone (UTC by default):
sync:
function: contacts-sync
schedule: "*/15 * * * *"OpenCloud runs it once per active connection that is bound into at least one app, starting shortly after the first binding. Each run receives {} as input, may use the full 15-minute Function timeout, and never overlaps an earlier run for the same connection. A failed run waits for the next scheduled occurrence. People who manage the connection and the provider app's editors can also choose Run now.
Sync, webhooks, and events need runtime SDK 2.6.0, whose Function context names the connection:
export default defineFunction({
input: schema.object({}),
handler: async ({ integration, data, secrets }) => {
const response = await fetch("https://api.acme.example/contacts", {
headers: { authorization: `Bearer ${secrets.require("OAUTH_ACCESS_TOKEN")}` },
});
const { contacts } = await response.json();
const table = data.table("contacts");
for (const contact of contacts) {
// RLS limits every read and write to this connection's rows.
const [existing] = await table.list({ where: { external_id: contact.id }, limit: 1 });
if (existing) await table.updateById(String(existing.id), { name: contact.name });
else await table.create({ external_id: contact.id, name: contact.name });
}
return { synced: contacts.length, connection: integration?.connectionId };
},
});integration is null outside an integration invocation. Otherwise it has connectionId, trigger (operation, sync, webhook, or oauth), and, for operations, the operation and consumerAppId. Provider Functions run with the app's system identity, and auth.connection_id() returns the same connection in SQL, so an RLS policy keeps each connection's rows apart; see Migration SQL. Operation Functions can then answer from the synced rows instead of calling the external system every time.
Receive webhooks and publish events
Declare a webhook Function to receive requests from the external system:
webhook:
function: webhook-receive
events:
- type: contact.created
capability: contacts.read
description: A contact was created.
fake: { contactId: c_1, name: Ada Lovelace }Every connection gets its own webhook URL, shown to the people who manage it. Treat it as a secret: it authorizes posting to that connection. OpenCloud forwards each POST of at most 256 KiB, of any content type, to the webhook Function as { method, headers, body, receivedAt }. body is the exact request text, so the Function can verify the sender's signature with the connection's credentials. Cookies, forwarding headers, and x-opencloud-* headers are removed. A 2xx result is returned to the sender; 401 and 403 are passed through, other 4xx results become 400, and failures become 503 so the sender retries.
Functions run on Deno, so there is no global Buffer. For an HMAC-SHA256 signature such as GitHub's X-Hub-Signature-256, Web Crypto verifies in constant time:
async function signedBy(secret: string, body: string, header: string | undefined) {
const hex = header?.replace(/^sha256=/, "") ?? "";
if (!/^[0-9a-f]{64}$/.test(hex)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const signature = Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
return crypto.subtle.verify("HMAC", key, signature, encoder.encode(body));
}events declares the changes consumer apps may subscribe to. Types are dotted lowercase names such as contact.created, and each belongs to a declared capability. Operation, sync, and webhook Functions publish them for their connection:
await integration.emit({
type: "contact.created",
id: contact.id,
data: { contactId: contact.id, name: contact.name },
});id is the provider's stable identifier for the change. OpenCloud records each event ID once per subscribed binding, so emitting the same change again creates no new delivery. data is a JSON object of at most 64 KiB. emit returns the number of new deliveries. It is unavailable during OAuth exchange and refresh.
Connect an account
Under Account → Integrations → Organisation integrations, open the integration and choose Connect, or Connect with … for an OAuth integration. Values and tokens are write-only: OpenCloud encrypts them and never shows or returns them again. Replace credential values, or sign in again to reconnect, from the same page. Replacing the values of a connection marked Reconnect required restores it.
- A personal connection belongs to you. Only you can bind it, and only into apps you administer.
- An organisation connection is created by an organisation administrator. Anyone who may use the integration can bind it into apps they administer; only organisation administrators can replace or remove it.
Removing a connection removes every binding that uses it.
The provider app's operation Functions receive the values on every call, so anyone who can change that app's code — its administrators, and an Agent acting for them — controls what happens to the credentials. Connect only to integrations whose administrators you would trust with the credential itself, and restrict the provider app's Administer audience accordingly.
Use an integration from an app
Declare a slot with provider: custom, the published integration name, the app account, and only the capabilities the app needs. Consumer apps need runtime SDK 2.2.0 or later.
integrations:
erp:
provider: custom
integration: acme-erp
account: app
cardinality: one
capabilities:
- orders.readconst { orders } = await integrations
.use("erp")
.call("orders.list", { status: "open" });Use account: calling_user when each person should use their own account instead of one shared by the app:
my_crm:
provider: custom
integration: acme-crm
account: calling_user
capabilities: [contacts.read]A calling-user call acts with the signed-in person's own personal connection. Without one it fails with INTEGRATION_CONNECTION_REQUIRED; show the person details.connectUrl, which lets them connect and bind their account to this app in one step. On SDK 2.6.0 the URL is on the error in the Function, and an uncaught error carries the same details to the browser:
try {
return await integrations.use("my_crm").call("contacts.list", {});
} catch (error) {
if (error instanceof OpenCloudError && error.code === "INTEGRATION_CONNECTION_REQUIRED") {
return { connectUrl: (error.details as { connectUrl: string }).connectUrl };
}
throw error;
}Calls without a signed-in user fail with INTEGRATION_USER_REQUIRED. Only the person can bind their calling-user connection, so Agents cannot bind these slots.
To receive a provider's events, give an account: app slot a system event handler and, optionally, the event types it wants:
crm:
provider: custom
integration: acme-crm
account: app
capabilities: [contacts.read]
events:
function: on-contact
types: [contact.created]The handler receives { id, type, occurredAt, integration, provider, bindingId, data }, where integration is the slot name. It runs as the app's system identity, so give the table it writes a policy for all commands, such as using (auth.is_system()) with check (auth.is_system()): create returns the new row, so an insert-only policy fails. Delivery is at least once and retried with backoff for about three hours, including while the app is being deployed, so handle id idempotently. A slot receives an event only while its binding is active and grants the event's capability. Inspect recent deliveries, their attempts, and the last error with list_integration_event_deliveries or GET /v1/apps/{appId}/integration-events. Event payloads are discarded once delivered, and delivery records are kept for 14 days.
An app administrator binds a connection under App → Integrations, and an exact-app owner Agent may bind an existing eligible connection through the same app integration API it uses for built-in providers. Agents list published contracts with the list_custom_integrations MCP tool or GET /v1/apps/{appId}/custom-integrations. Entering credential values is always a human action. Validation warns when no active app publishes the integration or when a declared capability is not offered.
At runtime OpenCloud checks the slot, the operation's capability, the binding's grant, and live authority on every call:
- the consumer, provider, and connection must share an active organisation;
- the person who created the binding must still be allowed to use the provider app, so narrowing the provider's Use audience suspends their bindings;
- a personal connection also needs its owner's continuing Edit access to the consumer app. Losing that access, or leaving the organisation, revokes the delegation permanently; bind the connection again after regaining access.
Each call is audited with the provider, consumer app, connection, operation, environment, result, and duration, without inputs, outputs, or credentials.
Develop and test
Development sessions return each operation's declared fake output and never contact the provider unless you switch a slot to live mode:
set_dev_integration_mode { appId, sessionId, integrationName: "erp", mode: "live" }Live mode applies to one app-account slot in one development session and uses the app's production binding, so calls reach the real system and are audited as development traffic. Use it to confirm real data shapes, authentication, pagination, and errors before deploying, prefer read operations, and switch back to fake afterwards. calling_user slots and verification sandboxes always stay fake. Live mode is available for built-in app-account slots too. Development email is always captured and never sent.
Production events are never delivered to development sessions. Test an event handler with a synthetic event instead; data defaults to the provider's declared fake payload for that type:
inject_dev_integration_event { appId, sessionId, integrationName: "crm", type: "contact.created" }A provider app tests its own operations in its development session with a test connection: one of the selecting person's own connections, or an organisation connection, chosen with set_dev_integration_test_connection by someone with Edit access to the provider app. Its development operation Functions then receive those credential values. Prefer a sandbox or read-only connection. Invoke the operation, sync, and webhook Functions with invoke_dev_function as usual: they act for the test connection, so integration.connectionId and auth.connection_id() match it, and integration.emit validates each event against the development revision without delivering it. Development Functions receive the connection's current OAuth access token; production use refreshes it. Without a test connection, invoking one of these Functions fails with INTEGRATION_TEST_CONNECTION_REQUIRED. OAuth exchange and refresh never run in development (INTEGRATION_OAUTH_FUNCTION_NOT_INVOCABLE); test them by connecting an account.
Development verification does not require invoking the Functions a contract names (operations, sync, webhook, exchange, and refresh), because they run only with a connection. Keep them free of development-only branches and hard-coded data: consumers already receive the declared fake output, and a test connection must reach the real code.
Limits
- Sharing stays inside the provider app's organisation.
- OAuth uses the authorization-code flow with the provider app's exchange and refresh Functions; device and client-credentials flows are not built in.
- Webhook bodies are limited to 256 KiB and event data to 64 KiB. Event delivery is at least once, not ordered.
- Sync runs only for connections bound into at least one app.
