Data Connectors

How Data Connectors Run

Learn where data connector code runs, what it can use, how it reaches the network, and how data sources start, reload, and restart.
Preview
Extensions are in preview and can change in breaking ways between releases. Use them in non-production environments only. See Stability. Written for Monospace 1.0.0, @monospace/cli 0.2 and @monospace/extension-kit 0.3.

Overview

This page describes how the entries of a data connector extension run. Data connectors are the only extension entry type today, and the sandbox, lifecycle, and limits below apply to them specifically.

Engine runs each data connector extension inside a sandbox. Knowing its boundaries up front saves you from code that builds cleanly and then fails at runtime.

A data source is a connector configured in a workspace. It points at the external data source, the API, service, or database the connector reads and writes.

The model in one paragraph: every data source gets its own isolated Deno worker. The worker loads one self-contained JavaScript file and has web platform APIs but no Node APIs. It reaches only the hosts the data source was granted, and keeps nothing between restarts.

One Worker per Data Source

PropertyBehavior
IsolationEach data source runs in its own Deno worker. Two data sources from the same connector share no memory
CodeOne JavaScript file per entry, produced by the CLI build. The worker loads that file and nothing else
StateVariables you create in setup live as long as the worker. They are lost when the worker restarts
ConcurrencyOne worker serves several requests at the same time. Treat shared state in setup as concurrently accessed
TimingModule evaluation and setup each run under a timeout. Calls to your handlers have no deadline. A worker whose code blocks the thread stops responding and gets replaced. See Data Connector Runtime Limits

Single File, No Imports

The worker refuses every module import. The CLI bundles your TypeScript and its dependencies into one file, and fails the build if the output still imports anything.

You can use npm packages that run on web APIs alone, because the build inlines them. You can't use packages that need Node built-ins.

import { defineDataConnector } from '@monospace/extension-kit/data-connector';

A build that still imports a module fails with extension.built_not_self_contained. A file that bypasses the CLI and imports a module fails to load with an extension cannot import …. A dynamic import() fails with the same message when it runs.

Available APIs

The worker exposes the web platform APIs of the Deno runtime, and nothing that reaches outside the sandbox.

Tip
Need a Node.js-compatible API? Let us know.
AvailableNot available
fetch, Request, Response, HeadersNode APIs (node:* modules, require)
URL, URLSearchParamsnpm packages resolved at runtime
crypto, TextEncoder, TextDecoderFilesystem reads and writes
AbortController, AbortSignal.timeout, setTimeoutEnvironment variables
ReadableStream, structuredCloneSubprocesses
consoleWeb workers
Persistent storage of any kind
Engine's stdin, stdout, and stderr. Deno.stdin reads nothing, and writes to Deno.stdout and Deno.stderr are discarded

Filesystem, environment, and subprocess calls throw Deno.errors.NotCapable.

Configuration, Not Environment Variables

A connector gets its settings and credentials from the data source's configuration, never from the environment. The Stripe connector from Build a Stripe Data Connector parses its configuration in setup. Its parseConfig throws InvalidConfiguration when the configuration is invalid:

import { defineDataConnector } from '@monospace/extension-kit/data-connector';
import { StripeClient } from './client';
import { schemaDocument } from './collections';
import { parseConfig } from './config';
import { readMany, readOne } from './read';
import { findResource } from './resources';
import { create, deleteMany, deleteOne, updateMany, updateOne } from './write';

export default defineDataConnector<unknown>({
    setup({ config: rawConfig }) {
        const config = parseConfig(rawConfig);
        const context = { client: new StripeClient(config.apiKey), config };

        return {
            introspect() {
                return schemaDocument(config.enableWrites);
            },

            async query({ query }) {
                // Engine sends only declared collections, and this connector declares no namespaces.
                // For anything else, throw a plain error.
                const resource = query.namespace === undefined ? findResource(query.collection) : undefined;
                if (resource === undefined) throw new Error(`Unknown collection \`${query.collection}\`.`);

                switch (query.op) {
                    case 'readMany': return await readMany(context, resource, query);
                    case 'readOne': return await readOne(context, resource, query);
                    case 'create': return await create(context, resource, query);
                    case 'updateMany': return await updateMany(context, resource, query);
                    case 'updateOne': return await updateOne(context, resource, query);
                    case 'deleteMany': return await deleteMany(context, resource, query);
                    case 'deleteOne': return await deleteOne(context, resource, query);
                    default: {
                        // `query` is `never` here, so an operation added to the kit fails to compile until it's handled.
                        const unhandled: never = query;
                        throw new Error('The Stripe connector received an operation it doesn\'t handle.', { cause: unhandled });
                    }
                }
            },

            async testConnection() {
                await context.client.ping();
                return { ok: true };
            },
        };
    },
});

HTTP Through fetch

Use fetch for every call to the external data source. It sends a Monospace/<version> user agent unless you set User-Agent yourself. Engine sets no deadline on query, introspect or testConnection, so a request that never answers holds the caller's request open until the caller gives up. Give every outgoing request a timeout. It bounds that one request, not the whole operation: a read that pages sends several requests, each with its own deadline.

This excerpt from the full Art Institute of Chicago connector, from Map an External Data Source, sends one request with a 10-second deadline. It leaves out the imports, the types, and the helpers retryAfter and parseSearchResult:

src/data-connectors/artic/search.ts
const BASE_URL = 'https://api.artic.edu/api/v1';
/** Engine sets no deadline on `query` or `testConnection`, so every request carries its own. */
const TIMEOUT_MS = 10_000;
// …
/**
 * Sends one search request, and classifies every failure. Only an error's code and message leave the
 * connector, so the message names the API and the status, never the response body.
 */
export async function search(collection: string, body: SearchBody): Promise<SearchResult> {
    let response: Response;
    let text: string;

    try {
        response = await fetch(`${BASE_URL}/${collection}/search`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'AIC-User-Agent': 'monospace-artic-connector' },
            body: JSON.stringify(body),
            // The deadline covers reading the body too.
            signal: AbortSignal.timeout(TIMEOUT_MS),
        });
        text = await response.text();
    }
    catch (error) {
        const isTimeout = error instanceof Error && error.name === 'TimeoutError';
        const reason = isTimeout ? `did not answer within ${TIMEOUT_MS / 1000} seconds` : 'could not be reached';
        // Keep the rejection as the `cause`: when the data source's permissions refused the request,
        // Engine finds that there and reports a permission error instead of a failed connection.
        throw new ConnectionFailed(`The Art Institute API ${reason}.`, { cause: error });
    }

    if (response.status === 429) {
        throw new RateLimited(`The Art Institute API rate limited the connector.${retryAfter(response)}`);
    }

    // Treat a server error as an outage: a later request may work.
    if (response.status >= 500) {
        throw new ConnectionFailed(`The Art Institute API answered HTTP ${response.status}.`);
    }

    // Any other failed status means the connector sent a request the API refuses.
    if (!response.ok) {
        throw new Error(`The Art Institute API answered HTTP ${response.status}.`);
    }

    return parseSearchResult(text);
}
  • AbortSignal.timeout(TIMEOUT_MS) gives the request its deadline. When it passes, fetch rejects with an error named TimeoutError.
  • Read the body inside the same try. The signal also aborts reading the body, so the deadline covers the whole response.
  • Keep the rejection as the cause. A request to a host outside the data source's grant rejects with an error named NotCapable. Engine looks for it along the thrown error's cause chain, so ConnectionFailed with { cause: error } still reaches the caller as a permissions error, with your message as the innermost one. Without cause, the denial reads as a failed connection. See Handle a Denied Request.

Logging

Write logs with console. Engine discards stdout and stderr, so anything written there never appears. This logs the status of the fetch response above:

console.info('fetched artworks page', response.status);

Every console call lands in the Engine log at info level, tagged with the data source and the connector id. Engine also exposes monospace.log(message, fields). It logs fields as JSON in a single params attribute instead of formatting them into the message.

No Persistence

The worker has nowhere to store data. Keep API clients, tokens, and caches in variables that setup creates, like the Stripe connector's context above. Expect to rebuild them after a restart.

Writing to disk throws instead:

❌ throws NotCapable
await Deno.writeTextFile('./cursor.json', JSON.stringify({ offset: 100 }));

Network Access

A worker reaches only the hosts its data source was granted. An empty grant means no network at all.

The connector declares the hosts it needs in two places:

  • Fixed hosts go in extension.config.json as net: permissions with a reason (e.g., net:api.stripe.com).
  • Hosts that depend on configuration, such as a configurable base URL, come from the connector's optional preflight.

Whoever creates the data source approves the combined set. A fetch to any other host rejects with Deno's NotCapable error, and so does a redirect from a granted host to one outside the grant. Keep it as the cause when you wrap it, as HTTP Through fetch shows. See Network Permissions for the format and the approval flow.

Data Source Lifecycle

Engine starts a data source's worker when it first needs it. Every start walks the same sequence, from load to setup. Connection tests and schema previews start a temporary worker of their own, with the same sequence.

Load

Engine evaluates the connector file. Code at module scope runs here, under the load timeout, with the network permissions the data source was granted. Keep network calls out of module scope: this step runs before the permission check, so a call to a host the sent grant lacks fails the start before Engine can report the permissions the connector requires.

Preflight

If the connector defines preflight({ config }), Engine calls it to collect the network permissions the configuration implies. preflight is synchronous and returns permissions only. It has no timeout of its own: a preflight that blocks falls under the liveness check.

Permission Check

Engine compares the data source's granted permissions with the manifest's permissions plus those from preflight. The two sets must contain exactly the same hosts, ports, and wildcards. Reasons aren't compared. If the sets differ in either direction, the worker stops here.

Setup

Engine calls setup({ config }) under the setup timeout. setup validates the configuration, builds the client for the external data source, and returns the handlers.

Serve

Once set up, the worker answers calls on demand. Engine calls introspect() when it needs the schema, such as when a data source is created, previewed, or reintrospected. It calls query(request) for each operation. It calls the optional testConnection() when a connection test is requested, and before it creates a data source or previews a schema: a testConnection that throws or answers { ok: false } stops both. Without testConnection, a connection test answers 422 with code 7002, and creating a data source still works.

setup runs once per worker, not once per request. Anything it creates is shared by every request that worker serves.

Configuration

A data source's configuration is the JSON object you enter when you create it. In Studio, that's a raw JSON editor. Through the REST API, it's the request's config object, sent next to the approved permissions.

PropertyBehavior
ValidationEngine doesn't validate configuration. The connector parses it in setup and throws InvalidConfiguration when it's wrong
StorageEngine stores the configuration and the granted permissions encrypted
ChangesNo API updates an existing data source's configuration or permissions. Recreate the data source to change them

Restarts and Failures

EventWhat happens
The worker crashes or stops respondingRequests in flight fail. With none in flight, the first request sent to it fails. The next request starts a new worker, and setup runs again. Failed requests aren't replayed
A new worker is startingOther requests to the data source fail instead of waiting
setup throws InvalidConfiguration or AccessDenied, a request it sends is refused by the data source's permissions (uncaught, or kept as the cause), or it returns no introspect or query handlerThe data source stays stopped until the extension is reloaded
The file fails to load, preflight returns malformed permissions, or the granted permissions don't match the required onesThe data source stays stopped until the extension is reloaded
Loading or setup times out, or setup throws another kit error class or a plain errorThe request fails, and the next request tries to start the worker again

The table describes a data source's own worker. A connection test or schema preview runs in a temporary worker, so a failure there, including one that deactivates it, doesn't affect the data source or the next test.

A thrown error inside query fails that request only. A readOne, updateOne or deleteOne that finds no matching item (a single data object in a collection) answers { record: null } instead of throwing. Unhandled promise rejections are logged and don't stop the worker. A rejection message longer than 4,096 UTF-16 code units is cut there and ends with …. console output isn't cut.

A handler that resolves can still fail the request. Engine checks the answer after the handler returns: an item missing a field the request selects, a value Engine can't convert to its field type, or a readOne, updateOne or deleteOne that answers records instead of record fails the request. For a write, the external data source has already applied the change, and Engine can't roll it back. See Answer a Query.

Install and Reload

Engine loads connector code from the extensions in its install location, at startup and, with MONOSPACE_EXTENSIONS__AUTO_RELOAD on, whenever an installed extension changes. After a reload, each data source whose entry id is still in the new build switches to the new code on the next request that reaches its connector. A data source whose entry was removed or renamed keeps running the old code until its worker restarts.

A reload swaps code only. Each data source keeps the schema it was created with, its configuration, its granted permissions, and the query capabilities its workspace was built with. A reloaded build that changes the entry's capabilities deactivates its existing data sources: requests that reach their connector fail with 503 until the instance restarts or the workspace is rebuilt. See When the Capabilities Change. See Update Data Sources After an Extension Update for what to do after each kind of change, and Install and Manage Extensions for the install location and reload settings.

See Also

MonospaceThe governed API layer for every app, person, and agent.

Copyright © 2026 Monospace Inc.