OOMOL Connector SDK guide
Last updated on June 29, 2026
@oomol-lab/connector is a lightweight, zero-dependency TypeScript client for the OOMOL Connector gateway. It calls provider actions such as gmail.search_threads, slack.post_message, and notion.append_block with one typed line. Authentication, OAuth, token refresh, and credential storage live on the gateway; the SDK builds the request and parses the response. No codegen or CLI required.
Main topics:
- How to install the SDK, get an API key, and make your first call.
- The five words that describe the whole model: gateway, provider, action, connection, organization.
- The two call paths (dynamic string and namespace) and how to read execution metadata.
- How to opt into precise per-action types without any codegen.
- How to proxy un-modeled endpoints, introspect the catalog, and list connected apps.
- How to use
ProjectConnectorto connect accounts on behalf of your end users and run actions for them. - How errors are shaped, which ones are retryable, and how to scope, time out, and cancel calls.
Install
npm install @oomol-lab/connector # or: bun add / pnpm add / yarn add
Requires Node ≥ 18, for the built-in fetch and AbortController. The SDK ships only dist, has zero runtime dependencies, and is sideEffects: false, so it tree-shakes cleanly. It runs in Node, Bun, Deno, edge runtimes, and browser-based apps with a standard fetch; in browsers, inject the API key from the host.
Get an API key
You need an OOMOL Connector personal API key, shaped like api_…. Create one in the OOMOL Console:
https://console.oomol.com/api-key
Set it as an environment variable. This guide uses OOMOL_API_KEY throughout. The gateway authorizes every request and receives the key as Authorization: Bearer <apiKey>.
A personal
api_…key runs actions on your own connections. To connect accounts for your end users, use a separate project key (oo_proj_…) and theProjectConnectorclient; see Connect accounts for your users.
Quickstart
Construct a client, then call an action. The two forms below are equivalent.
import { Connector } from "@oomol-lab/connector";
const oomol = new Connector({ apiKey: process.env.OOMOL_API_KEY! });
// Path 1: dynamic string. Callable for any action id.
const { threads } = await oomol.execute("gmail.search_threads", { query: "from:boss" });
// Path 2: namespace sugar. Same call underneath.
const result = await oomol.gmail.search_threads({ query: "is:unread" });
execute returns the action output directly. When you also want the execution metadata, use executeRaw:
const raw = await oomol.executeRaw("gmail.search_threads", { query: "from:ceo" });
raw.data; // the same value execute() returns
raw.executionId; // server-assigned execution id (useful for support / log correlation)
raw.actionId; // echoed action id
raw.message; // human-readable message from the success envelope
The core call flow is execute and executeRaw.
Concepts
The SDK model has five core concepts. Authentication, credentials, and provider calls are handled by the gateway:
| Term | What it is |
|---|---|
| Gateway | The hosted OOMOL Connector service this client talks to. It holds credentials, performs the actual provider calls, and returns a uniform envelope. The SDK runs no integration logic locally. |
| Provider / service | A third-party API (gmail, slack, github, notion, …). It is the <service> prefix of an action id. |
| Action | One operation on a provider, identified as "<service>.<action>" (e.g. gmail.search_threads). Actions are provided by the gateway and called by the client. |
| Connection | A stored, already-authorized credential for a provider. You never touch tokens; you name which connection to use via connectionName. OAuth and credential lifecycle are the gateway’s job. |
| Organization | Optional tenant scoping, sent as the x-oo-organization-name header. |
The same vocabulary carries into the SDK surface: an action id is always "<service>.<action>", connectionName selects a stored connection, and, for the project client, externalUserId identifies one of your end users.
Common operations
| Want to… | Use | Notes |
|---|---|---|
| Run a modeled action | execute / executeRaw | The typed one-liner. executeRaw also returns { executionId, actionId, message }. |
| Hit an endpoint not yet modeled as an action | proxy | Passthrough to the upstream API, with the connection’s credentials injected by the gateway. |
| Feed actions to an LLM / build dynamic forms | catalog | Runtime JSON Schema (2020-12) for any action or provider. |
| Discover what’s connected | apps.list | Read-only list of the connections you’ve already linked. |
| Let your users connect their accounts | ProjectConnector | A separate, project-scoped client to connect accounts on behalf of your end users and run actions for them. |
Provider and action coverage is supplied by the gateway. It currently supports 600+ providers and keeps growing; discover it at runtime with oomol.catalog.providers().
Precise types (optional)
The dynamic string path compiles for any actionId. By default, every action is loosely typed (Record<string, any> in and out), which keeps new actions callable immediately. For precise per-action input/output types plus JSDoc, install the companion types package and add one side-effect import per provider you use:
import { Connector } from "@oomol-lab/connector";
import "@oomol-lab/connector-types/gmail"; // precise types + JSDoc for gmail.*
import "@oomol-lab/connector-types/slack"; // …and slack.*
const oomol = new Connector({ apiKey: process.env.OOMOL_API_KEY! });
await oomol.gmail.search_threads({ query: "from:boss" }); // input + output now precise
await oomol.notion.append_block({ pageId, text }); // notion not imported → still loosely callable
npm install -D @oomol-lab/connector-types
No codegen, no committed files, no CLI. Registered actions get literal completion and exact input/output types; unregistered ones degrade to Record<string, any>. When the types package lags the backend, new actions remain callable through the loose fallback. The core runtime does not depend on the types package.
Requires
moduleResolutionset tobundler,node16, ornodenextso the subpath imports (@oomol-lab/connector-types/gmail) resolve. See the@oomol-lab/connector-typesrepository for setup details.
Configuration
Every field except apiKey is optional:
new Connector({
apiKey: process.env.OOMOL_API_KEY!, // required
baseUrl: "https://connector.oomol.com/v1", // default
organization: "acme", // default org → x-oo-organization-name
connectionName: "work", // default connection (prefer per-call / using())
timeoutMs: 30_000, // default per-request timeout
maxRetries: 2, // default; retries 429 / 5xx / network with backoff + jitter
fetch: customFetch, // inject for tests / proxies / tracing
});
| Field | Default | Notes |
|---|---|---|
apiKey | — | Required. Sent as Authorization: Bearer <apiKey>. |
baseUrl | https://connector.oomol.com/v1 | Manual override only; there is no env-based auto-switching. |
organization | — | Which tenant the call runs under. |
connectionName | — | Which stored connection to use when a provider has more than one. Use it as a client default for simple setups; for multiple connections, set it per call or via using(). |
timeoutMs | 30_000 | Per-request timeout in milliseconds. |
maxRetries | 2 | Retries on 429 / 5xx / network errors, with exponential backoff and jitter. |
fetch | global fetch | Inject a custom fetch for tests, proxy agents, or tracing. |
Scopes and per-call options
Three layers resolve in this precedence: per-call options > using() scope > client defaults.
using() returns an immutable scoped sub-client that merges the given defaults; the original client is untouched:
const work = oomol.using({ connectionName: "work", organization: "acme" });
await work.gmail.search_threads({ query: "label:urgent" }); // runs under "work" / "acme"
Per-call options apply only to that call and have the highest precedence:
await oomol.execute(
"gmail.search_threads",
{ query: "from:ceo" },
{
organization: "acme", // override org for this call
connectionName: "alt", // pick a different connection for this call
timeoutMs: 10_000, // tighter timeout for this call
retries: 0, // disable retries for this call
signal: controller.signal, // forward an AbortSignal
},
);
connectionName resolves with the same layering. It is carried on the wire as the x-oo-connector-alias header (the gateway field name is alias); the SDK surface uses connectionName.
Two clients at a glance
The package exports two clients: one for personal connections and one for end-user connections under a project. They share the transport and error model; their API keys, methods, and types are separate.
Connector | ProjectConnector | |
|---|---|---|
| API key | personal api_… | project oo_proj_… |
| Acts on | your own connections | your end users’ connections |
| Identifies a user | — | externalUserId (you choose it) |
| Surface | execute, proxy, catalog, apps, namespaces | connect.*, waitForConnection, execute, forUser |
| Use it to | call providers you’ve connected | build a SaaS product where each user links their own accounts |
This section starts with the personal Connector. ProjectConnector is covered in Connect accounts for your users.
Proxy: call an endpoint that has no action yet
When the gateway has not modeled an endpoint as an action, call it directly with proxy. The gateway still injects the connection’s credentials; the request and response keep the upstream API shape.
// Typed GET. NOTE the field is `endpoint`, NOT `path`.
const repos = await oomol.proxy<Array<{ name: string }>>("github", {
endpoint: "/user/repos",
method: "GET",
query: { per_page: 5, sort: "updated" },
});
repos.status; // upstream HTTP status
repos.data.map((r) => r.name);
// POST with a body and upstream headers (these go to the provider, not the gateway).
await oomol.proxy("github", {
endpoint: "/repos/acme/widgets/issues",
method: "POST",
headers: { "X-GitHub-Api-Version": "2022-11-28" },
body: { title: "Tracking issue", labels: ["chore"] },
});
endpoint accepts either a path (resolved against the provider’s base URL) or a full URL, which is useful for providers with regional hosts such as https://eu.posthog.com/api/.... method is one of GET | POST | PUT | PATCH | DELETE. The response is { status, headers, data }. The proxy body is strict on the backend: unknown top-level keys are rejected as invalid_input.
Catalog: introspect providers and actions
The catalog provides read-only runtime metadata for dynamic forms, validation, and LLM tool definitions. Input/output schemas use JSON Schema (2020-12) and are independent of the compile-time types package.
// List providers; optionally narrow server-side.
const all = await oomol.catalog.providers(); // every provider
const mailish = await oomol.catalog.providers({ q: "mail" }); // free-text search → ?q=
const some = await oomol.catalog.providers({ service: ["gmail", "slack"] }); // restrict → ?service=…
// All actions of one service.
const actions = await oomol.catalog.actions("gmail");
// Full metadata for one action, including runtime JSON Schema.
const meta = await oomol.catalog.action("gmail.search_threads");
meta.name; // human-readable name
meta.requiredScopes;// OAuth scopes the action needs
meta.inputSchema; // JSON Schema (2020-12) for the input
meta.outputSchema; // JSON Schema (2020-12) for the output
Each provider carries { service, displayName, iconUrl, homepageUrl, categories, authTypes }.
Apps: list your connected accounts
apps.list() returns a read-only view of the connections the gateway already holds for you. Connection creation and removal happen in Console.
const apps = await oomol.apps.list();
for (const app of apps) {
// { id, service, status, connectionName, … }; connectionName is null when none is set.
console.log(`${app.service}: id=${app.id} status=${app.status} connectionName=${app.connectionName}`);
}
// Target a specific connection by passing its connectionName back as the per-call selector.
const work = apps.find((a) => a.connectionName === "work");
if (work) {
await oomol.execute("gmail.search_threads", { query: "is:unread" }, { connectionName: "work" });
}
Error handling
Failures throw a typed ConnectorError. Caller cancellation (an aborted AbortSignal) rejects with the standard AbortError, so it can be handled separately from gateway or transport errors.
import { Connector, ConnectorError, isRetryable } from "@oomol-lab/connector";
try {
await oomol.slack.post_message({ channel: "#general", text: "shipped" });
} catch (err) {
if (err instanceof ConnectorError) {
err.code; // discriminable union, e.g. "rate_limited", "credential_expired"
err.status; // HTTP status (0 for client-side / network errors)
err.requestId; // failure-correlation id
err.actionId; // when applicable
err.executionId; // when applicable
err.data; // upstream response body, e.g. on provider_error
if (isRetryable(err)) {
// 429 / 5xx / network / rate_limited / proxy_upstream_timeout / request_in_progress
}
} else {
throw err; // non-ConnectorError, e.g. AbortError from caller cancellation; rethrow
}
}
err.code is an open union: known backend codes get autocompletion, and new backend codes still pass through as strings. Keep a default branch when handling it. Common codes, grouped:
| Group | Codes |
|---|---|
| Input / request | invalid_input, invalid_request_payload, invalid_request_signature |
| App / provider | app_not_found, app_not_ready, app_auth_type_mismatch, provider_not_found, provider_not_configured, provider_config_not_found, provider_error |
| Credential / auth | credential_expired, scope_missing, user_oauth_client_required |
| Connection selection | connection_ambiguous, connection_account_conflict, connection_alias_conflict, connection_request_not_found, connected_account_not_found |
| Proxy | proxy_not_supported, proxy_upstream_error, proxy_upstream_timeout, proxy_response_too_large |
| Rate / concurrency | rate_limited, request_in_progress, request_key_conflict, request_key_used |
| Client-only (status 0, no request sent or transport failure) | client_invalid_request, client_timeout, client_network_error, client_wait_timeout |
isRetryable(err) returns true for rate_limited, proxy_upstream_timeout, request_in_progress, HTTP 429, any 5xx, and transport failures (status 0). It returns false for client validation errors (client_invalid_request) and the waitForConnection cap (client_wait_timeout); those cases usually need a call or waiting-flow change.
Cancellation and timeouts
Forward an AbortSignal to cancel; set timeoutMs to bound a single call. The built-in retry layer handles transient failures. Use retries: 0 for a single deterministic attempt.
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);
try {
await oomol.execute("gmail.search_threads", { query: "huge" }, { signal: controller.signal });
} catch (err) {
(err as Error).name; // "AbortError"
}
Connect accounts for your users
Connector runs actions on your own connections. ProjectConnector is for SaaS products: your end users link their own Gmail / Slack / GitHub / … accounts through your app, and your backend runs actions on their behalf. This is the managed-auth model used by products such as Composio and Pipedream Connect.
With managed auth, credentials never pass through your application or any model. Your user authorizes on a gateway-hosted page; the gateway stores the credential and refreshes the token automatically. Your code holds only opaque identifiers.
The end-user identifier
externalUserId is the user-isolation key for the project client. You choose it, typically from your own user database. Project operations such as connecting an account, waiting for a connection, and executing an action are scoped to it. Pass the same externalUserId consistently and the gateway keeps each user’s connections isolated.
Construct the project client
ProjectConnector is a separate client, built with a project API key (oo_proj_…). It exposes only project-scoped operations and does not include the personal client’s execute / namespace / proxy / catalog / apps surface.
import { ProjectConnector } from "@oomol-lab/connector";
const project = new ProjectConnector({ apiKey: process.env.OOMOL_PROJECT_API_KEY! }); // oo_proj_...
Console setup comes first. Before your backend connects accounts, an administrator creates a project, a provider config, and a project API key in OOMOL Console. The one-time setup and matching backend REST flow are covered in the OOMOL Connector SaaS guide. This SDK is the typed wrapper over the same runtime API.
OAuth: create a link, then await completion
OAuth has two steps: create a pending connection request, send the user to authorize, then wait for completion.
// 1. Create a pending connection request for one of your users.
const request = await project.connect.oauth("user_42", {
service: "gmail",
connectionName: "work", // the name to assign; reuse it later to target this account
returnUri: "https://app.example.com/connected", // where the gateway returns the user after the callback
});
// 2. Send your user to the provider's authorization page.
redirectUserTo(request.authorizationUrl);
// 3. Poll until the user finishes (or it fails / expires). Returns the final connection request.
const connected = await project.waitForConnection(request);
connected.status; // "connected" | "failed" | "expired"
connected.connectedAccountId; // the stored account id once connected
The authorizationUrl first opens a Connector-hosted entry page that names your project and the provider the user is about to authorize, then sends the user to the provider’s OAuth page.

waitForConnection polls until the request leaves the initiated state and returns it. If the user does not finish authorization, the request naturally becomes expired. If maxWaitMs (default 600_000ms, matching the request’s expiry) elapses first, it throws a ConnectorError with code client_wait_timeout; an aborted signal rejects with the standard AbortError.
When your returnUri is hit, the gateway appends query parameters you can read on your callback page:
status=success
service=gmail
providerConfigId=pc-1
externalUserId=user_42
connectedAccountId=ca-1
…or, on cancellation / provider error:
status=error
code=<connector-error-code>
message=<human-readable-message>
API key / custom credential: synchronous
API-key and custom-credential connects return an account immediately. Only OAuth needs waitForConnection.
// The end user's own upstream key (e.g. an OpenAI sk-…), not your oo_proj_ key.
const account = await project.connect.apiKey("user_42", { service: "openai", apiKey: "sk-..." });
account.available; // whether the account can execute actions right now
// Provider-specific credential fields, validated by the gateway against the provider config.
await project.connect.customCredential("user_42", {
service: "jira",
values: { email: "user@acme.com", token: "..." },
});
Every connect.* call identifies the provider by exactly one of service or providerConfigId. Use providerConfigId when a project has more than one config for the same service; service is the simple case.
Execute on the user’s behalf
// The provider service is derived from the actionId prefix ("gmail").
// Without connectionName / connectedAccountId, the user's latest active account is used.
const out = await project.execute(
"user_42",
"gmail.search_threads",
{ query: "is:unread" },
{ connectionName: "work" },
);
Account selection precedence: connectedAccountId (a specific account) beats connectionName (an account by its name); with neither, the gateway uses the user’s latest active account for that provider. project.executeRaw returns the same { data, executionId, actionId, message } envelope as the personal client.
Scope to one user
forUser binds the externalUserId once so later calls do not repeat the id:
const user = project.forUser("user_42");
await user.connect.oauth({ service: "slack" });
const slack = await user.waitForConnection(/* the request above */ "req-id");
await user.execute("slack.post_message", { channel: "#general", text: "shipped" });
project.execute reuses the same @oomol-lab/connector-types registry as the personal path: imported providers get precise input/output, and the rest stay loosely callable.
Connection lifecycle
| Object | When you get it | Key fields |
|---|---|---|
ConnectionRequest | returned by connect.oauth, re-read via getConnectionRequest / waitForConnection | id, status (initiated → connected / failed / expired), authorizationUrl, connectedAccountId, externalUserId, connectionName, expiresAt |
ConnectedAccount | returned synchronously by connect.apiKey / connect.customCredential; pointed to by a completed OAuth request | id / connectedAccountId, status (active, reauth_required, error, disconnected), available, externalUserId, connectionName, service |
available is true only when everything needed to execute is in place: the provider config is active, the account is active, the underlying app is active, and a credential exists. Both status fields are open unions; keep a default branch to handle new backend statuses.
Coming from Composio / Pipedream?
| Composio / Pipedream | @oomol-lab/connector |
|---|---|
userId / external_user_id | externalUserId |
connectedAccounts.initiate / createConnectToken (OAuth) | project.connect.oauth |
connectedAccounts.initiate + AuthScheme.APIKey | project.connect.apiKey |
waitForConnection() | project.waitForConnection() |
tools.execute(slug, { userId, arguments }) | project.execute(externalUserId, actionId, input) |
composio.getEntity(userId) | project.forUser(externalUserId) |
Reference
Connector (personal api_… key)
new Connector(config: ClientConfig)
oomol.execute(actionId, input, options?) // → action output
oomol.executeRaw(actionId, input, options?) // → { data, executionId, actionId, message }
oomol.<service>.<action>(input, options?) // namespace sugar for execute
oomol.using(scope) // → immutable scoped sub-client
oomol.proxy(service, { endpoint, method, query?, headers?, body? }, options?) // → { status, headers, data }
oomol.catalog.action(actionId, options?) // → ActionMetadata
oomol.catalog.actions(service, options?) // → ActionMetadata[]
oomol.catalog.providers(query?, options?) // → ProviderMetadata[] query: { service?: string[]; q?: string }
oomol.apps.list(options?) // → ConnectedApp[]
ProjectConnector (project oo_proj_… key)
new ProjectConnector(config: ProjectConnectorConfig)
project.connect.oauth(externalUserId, input, options?) // → ConnectionRequest (pending)
project.connect.apiKey(externalUserId, input, options?) // → ConnectedAccount (synchronous)
project.connect.customCredential(externalUserId, input, options?) // → ConnectedAccount (synchronous)
project.getConnectionRequest(connectionRequestId, options?) // → ConnectionRequest
project.waitForConnection(requestOrId, options?) // → ConnectionRequest options: { pollIntervalMs?, maxWaitMs?, signal?, timeoutMs? }
project.execute(externalUserId, actionId, input, options?) // → action output
project.executeRaw(externalUserId, actionId, input, options?) // → { data, executionId, actionId, message }
project.forUser(externalUserId) // → ProjectUser (same methods, id bound)
connect.* input is { service | providerConfigId } & { connectionName?, … } (exactly one of service / providerConfigId). execute options add { providerConfigId?, service?, connectedAccountId?, connectionName? }.
Exports
import {
Connector,
ProjectConnector,
ConnectorError,
isRetryable,
} from "@oomol-lab/connector";
import type {
ClientConfig, CallOptions, ScopeOptions, RawResult,
ProxyRequest, ProxyResponse, ProxyMethod,
CatalogApi, ActionMetadata, ProviderMetadata, ProviderQuery,
AppsApi, ConnectedApp,
ConnectorErrorCode,
ProjectConnectorConfig, ProjectCallOptions, ProjectExecuteOptions,
ConnectionRequest, ConnectedAccount, ProviderSelector,
OAuthConnectInput, ApiKeyConnectInput, CustomCredentialConnectInput,
} from "@oomol-lab/connector";
Runnable, type-checked examples live in the repository’s examples/ directory.
License
MIT, see the connector-sdk repository.