Skip to content

Integrations for your customers (experimental)

View Markdown

Your product runs on LBB, and your customers want their own sources in it. Each customer gets one graph in your stack. For Notion, GitHub and Slack, your backend creates a consent link for an app your company owns. Other connectors accept the customer’s credential. LBB stores credentials sealed, applies the connector’s starter ontology and runs syncs. See Integrations for what it writes.

The routes live on the integrations API at https://api.littlebigbrain.com. They take your stack API key. One client of the SDK calls both that API and your stack endpoint.

  • Ask for developer access. An operator turns it on per stack. Write to hello@littlebigbrain.com and name the stack. Until then every route answers 403 integrations_api_disabled.
  • Keep the key on your servers. Any key of the stack reads every graph of the stack. Your backend is the boundary between your customers.
  • Use a second stack for tests, so test graphs do not count toward the limits of your product’s stack.

A graph id has 1 to 32 lowercase letters, digits, - or _. Derive an opaque id from your own customer id: c- and the first 24 hex digits of its SHA-256. It keeps names and email addresses out of storage keys and logs.

import { createHash } from "node:crypto";
export function graphFor(customerId: string): string {
return `c-${createHash("sha256").update(customerId).digest("hex").slice(0, 24)}`;
}
import hashlib
def graph_for(customer_id: str) -> str:
return "c-" + hashlib.sha256(customer_id.encode()).hexdigest()[:24]

Set the graph from your own record of the signed-in customer. Never take it from a field the customer sends.

baseUrl is the stack endpoint from the console’s Connect view. integrationsUrl is https://api.littlebigbrain.com by default.

import { LbbClient } from "@littlebigbrain/client";
const lbb = new LbbClient({
baseUrl: process.env.LBB_URL!,
apiKey: process.env.LBB_API_KEY!,
});
const graph = graphFor(customer.id);
import os
from lbb import LbbClient
lbb = LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"])
graph = graph_for(customer.id)

AsyncLbbClient has the same methods, with await. The option is integrations_url in Python.

For Notion, GitHub and Slack, use the opt-in developer-owned app flow instead: register your app, call providerApps() and createConnectSession(), then verify completion with getConnectSession(). Customers consent and select resources without an LBB account. Hosted connections for these providers refuse pasted tokens. The guide includes provider setup, return URLs and a runnable local example with disconnect.

The credential flow below applies to HubSpot, Linear, SharePoint and Google Drive.

connectors() lists each connector with the fields a customer fills in. Show those fields in your product.

Connector kind Credential fields
HubSpot hubspot HUBSPOT_TOKEN
Linear linear LINEAR_API_KEY
SharePoint sharepoint SHAREPOINT_TENANT_ID, SHAREPOINT_CLIENT_ID, SHAREPOINT_CLIENT_SECRET
Google Drive google_drive GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_REFRESH_TOKEN; or GOOGLE_SERVICE_ACCOUNT_KEY
const { connectors } = await lbb.integrations.connectors();
const hubspot = connectors.find((connector) => connector.kind === "hubspot");
console.log(hubspot?.credentialFields.map((field) => field.name));

The Integrations guide says where a customer creates each credential.

await lbb.integrations.create({
graph,
id: "hubspot",
kind: "hubspot",
credentials: { HUBSPOT_TOKEN: token },
});
lbb.integrations.create(
graph=graph,
id="hubspot",
kind="hubspot",
credentials={"HUBSPOT_TOKEN": token},
)

The call:

  1. seals the credential, bound to your stack, the graph and the connection;
  2. creates the graph when it does not exist;
  3. applies the connector’s starter ontology (starter: "skip" leaves it out);
  4. checks how the source fits the ontology;
  5. sends the first sync (start: false leaves it to you).

The same request again answers success and queues one first sync, so a retry is safe. The connection syncs every hour. everyMs (every_ms) sets another interval, at least 60,000 ms. null (None) syncs only on request.

Two answers create nothing:

  • 409 starter_conflict: the graph holds a starter term differently. details.conflicts lists the terms.
  • 409 integration_exists: the id has other credentials or settings in the graph. Choose another id, or replace the credentials.

Your customers connect Google Drive through your own Google app. Your backend asks the customer for consent. It sends the refresh token to LBB. LBB renews the access token before each sync.

  1. In Google Cloud, create an OAuth client of type web application. Add your callback URL as a redirect URI. Turn on the Google Drive API.
  2. Send the customer to the consent URL. It asks for read access to Drive, with offline access.
  3. On your callback, check state and exchange the code. Send the three values to create.
import { randomBytes } from "node:crypto";
import { googleDrive } from "@littlebigbrain/client";
const clientId = process.env.GOOGLE_CLIENT_ID!;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET!;
const redirectUri = "https://app.example.com/google/callback";
// The Connect Google Drive button. Keep `state` with the customer's session.
const state = randomBytes(16).toString("hex");
const consent = googleDrive.authorizeUrl({ clientId, redirectUri, state });
// The callback, after `state` matches.
const { credentials } = await googleDrive.exchangeCode({
clientId,
clientSecret,
redirectUri,
code,
});
await lbb.integrations.create({
graph,
id: "google-drive",
kind: "google_drive",
credentials,
config: { folders: ["https://drive.google.com/drive/folders/1AbCdEfGhIj"] },
});
import os
import secrets
from lbb import google_drive
client_id = os.environ["GOOGLE_CLIENT_ID"]
client_secret = os.environ["GOOGLE_CLIENT_SECRET"]
redirect_uri = "https://app.example.com/google/callback"
# The Connect Google Drive button. Keep `state` with the customer's session.
state = secrets.token_hex(16)
consent = google_drive.authorize_url(
client_id=client_id, redirect_uri=redirect_uri, state=state
)
# The callback, after `state` matches.
grant = google_drive.exchange_code(
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
code=code,
)
lbb.integrations.create(
graph=graph,
id="google-drive",
kind="google_drive",
credentials=grant.credentials,
)

A refused code raises GoogleOAuthError (TypeScript and Python) with Google’s code, such as invalid_grant. It also raises when Google returns no refresh token, or when the customer did not grant Drive access. exchange_code_async is the Python coroutine.

  • folders takes folder or shared drive links. Leave it out to sync every file the customer can read.
  • Google lists drive.readonly as a restricted scope. Your app needs Google’s verification before other Google accounts can use it.
  • While your app’s publishing status is Testing, a refresh token stops working after 7 days.
  • A customer can use a service account instead. They paste its JSON key as GOOGLE_SERVICE_ACCOUNT_KEY. They share the folders with its email.
const { connections } = await lbb.integrations.list({ graph });
for (const connection of connections) {
console.log(connection.id, connection.status.kind, connection.status.detail);
}
for connection in lbb.integrations.list(graph=graph)["connections"]:
print(connection["id"], connection["status"]["kind"], connection["status"]["detail"])

status.kind is for your code and status.detail is a sentence for a person. The console shows the same status.

kind Meaning
new It has not synced yet.
starting, syncing, working A turn runs or waits to start.
current Up to date.
review Some streams wait for an ontology change. The rest keep syncing.
retrying A turn failed and runs again.
delayed A turn waits on LBB’s side. It needs nothing from you.
attention A turn failed. Reauthorize a provider-app connection with a new connect session; other connectors can replace a refused credential with setCredentials.
paused Scheduled syncs wait until you resume.

get(id, { graph }) adds the settings, the current turn with its error, and the fit. Each answer also carries the last run, the totals and the counts of the fit. Read the list once a minute per graph. The host checks for scheduled syncs at that rate.

await lbb.integrations.sync("hubspot", { graph, idempotencyKey: "nightly-2026-10-03" });
lbb.integrations.sync("hubspot", graph=graph, idempotency_key="nightly-2026-10-03")

A request with the same idempotency key answers the same turn and queues nothing new. Without a key, the client makes one per call, so its own retries queue one sync. full: true reads every stream in full.

A connection created through this API runs in auto mode. Changes with strong support and a passing dry run apply without a person. Every change only adds terms, so queries on the starter’s terms keep working. The other changes wait as ontology suggestions. Until you accept one, the fields it covers are not written.

The suggestions live on your stack endpoint. The client reads them there.

const open = await lbb.integrations.suggestions("hubspot", { graph, status: "open" });
for (const suggestion of open.suggestions) console.log(suggestion.title);
await lbb.integrations.accept(open.suggestions[0].suggestion_id, { graph, sync: true });
await lbb.integrations.dismiss(open.suggestions[1].suggestion_id, {
graph,
reason: "We do not use this field.",
});
open_ = lbb.integrations.suggestions("hubspot", graph=graph, status="open")
for suggestion in open_.suggestions:
print(suggestion.title)
lbb.integrations.accept(open_.suggestions[0].suggestion_id, graph=graph, sync=True)
lbb.integrations.dismiss(
open_.suggestions[1].suggestion_id, graph=graph, reason="We do not use this field."
)

With sync: true, accept then sends the connection a sync. The records that waited are written now. The sync’s message id is sync-after-<suggestion id>, so accepting twice queues one sync. Pass change to accept an edited change. To keep review mode, where every change waits, create the connection with ontologyMode: "review".

The starter’s terms are the same in every customer’s graph. Query them with SPARQL through the typed constants of the starter.

import { starters } from "@littlebigbrain/client";
const { classes, properties } = starters.crm;
const { rows } = await lbb.withScope({ graph }).sparqlRows({
query: `
SELECT ?name ?domain WHERE {
?o a/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <${classes.Organization.iri}> ;
<http://www.w3.org/2000/01/rdf-schema#label> ?name .
OPTIONAL { ?o <${properties.domain.iri}> ?domain }
} LIMIT 10`,
});
from lbb.starters import crm
query = f"""
SELECT ?name ?domain WHERE {{
?o a/<http://www.w3.org/2000/01/rdf-schema#subClassOf>* <{crm.classes.Organization.iri}> ;
<http://www.w3.org/2000/01/rdf-schema#label> ?name .
OPTIONAL {{ ?o <{crm.properties.domain.iri}> ?domain }}
}} LIMIT 10
"""
with LbbClient(os.environ["LBB_URL"], api_key=os.environ["LBB_API_KEY"], graph=graph) as reader:
for row in reader.sparql(query):
print(row["name"], row.get("domain"))

The same query then serves every customer whose source maps onto the CRM starter. See Ontology starters.

Reads are eventual by default. Right after a sync ends, the new records can take a few seconds to appear. To read them at once, pass { consistency: "strong" } as the second argument of sparqlRows, or consistency="strong" to sparql in Python.

TypeScript Python Does
setCredentials(id, { graph, credentials }) set_credentials(id, graph=, credentials=) Replaces the credential, then checks it
setSettings(id, { graph, config }) set_settings(id, graph=, config=) Replaces the connector settings
pause(id, { graph }) pause(id, graph=) Stops scheduled syncs and queued work
resume(id, { graph }) resume(id, graph=) Continues them
delete(id, { graph }) delete(id, graph=) Deletes the connection’s state, credential and turn history

A deleted connection’s records stay in the graph. setSettings answers 409 credentials_required when the stored credential does not open under the new settings. Replace the credential first.

Notion, GitHub and Slack app connections reject direct credential/settings replacement. Reconnect through a new session with the existing connection ID and the same resources. Changing resources requires a new graph and rebuild; changing only the connection ID retains old data. Disconnect fences new provider reads through LBB and requests provider token revocation. Failed revocations retry independently of new connection capacity, with at most eight attempts or 24 hours before retaining a sealed diagnostic record. Exact tokens held by another live grant or consent for the same app are protected. Slack never uninstalls the workspace app. Imported data remains until graph erasure.

await lbb.integrations.erase(graph, { confirm: graph });
lbb.integrations.erase(graph, confirm=graph)

Erase deletes every connection of the graph with its credential, then deletes the graph. Reads and writes of the graph stop at once. A reclaim job removes the graph’s stored objects later. Each pass in reclaims has a mode: apply deletes, plan only counts. Erasing again answers zero counts. For provider apps, local access is fenced and revocation is queued before erasing the graph; provider outages or deleted app credentials do not block erasure.

Limit Default Past it
Graphs with integrations per stack 50 409 integration_limit_reached; ask us for more
Connections created per stack per hour 20 429 rate_limited with Retry-After
Requests per stack 600 per minute, 60 at once 429 rate_limited with Retry-After

A retried credential-based create of a connection that exists does not count. Each connect-session creation counts and is not automatically retried. A graph that has integrations takes more connections.

A failed call raises LbbError. It has status (status_code in Python), code and a message. A refusal adds details. On a 429, retryAfterSeconds (retry_after_seconds) holds the wait. For reads and safe writes, the client retries a 429 or a 5xx. Its retries stop after 60 seconds.

Status code Meaning
401 invalid_api_key No key, or an unknown, revoked or inactive one
402 subscription_required The account has no active subscription. Reads, pause, delete and erase still work.
403 integrations_api_disabled The stack has no developer access yet
403 stack_deletion_pending The stack is being deleted
404 No such connection in this graph of your stack
409 starter_conflict, integration_exists, integration_limit_reached, credentials_required See above
429 rate_limited Past a limit; wait Retry-After seconds
503 data_plane_busy, stack_endpoint_not_ready Briefly unavailable; retry
Method Path TypeScript Python
GET /v1/integrations/connectors connectors() connectors()
GET /v1/integrations/apps providerApps() provider_apps()
POST /v1/integrations/connect-sessions createConnectSession() create_connect_session()
GET /v1/integrations/connect-sessions/{id} getConnectSession() get_connect_session()
POST /v1/integrations/connections create() create()
GET /v1/integrations/connections?graph= list() list()
GET /v1/integrations/connections/{id}?graph= get() get()
PUT /v1/integrations/connections/{id}/credentials setCredentials() set_credentials()
PUT /v1/integrations/connections/{id}/settings setSettings() set_settings()
POST /v1/integrations/connections/{id}/sync sync() sync()
POST /v1/integrations/connections/{id}/pause, …/resume pause(), resume() pause(), resume()
DELETE /v1/integrations/connections/{id}?graph= delete() delete()
POST /v1/integrations/graphs/{graph}/erase?confirm={graph} erase() erase()

Each route takes Authorization: Bearer <stack API key>. suggestions(), accept() and dismiss() call the suggestion routes of your stack endpoint.