Build an outbound connector
Use an outbound connector when a capability needs an external API, SDK, database, queue, or internal platform service. The capability describes the domain outcome. The composition root resolves and supplies credentials. The connector owns provider authentication mechanics, transport, provider payloads, and failure translation.
An outbound connector is an authoring convention for a provider- or
technology-specific outbound adapter that crosses from engine logic into an
external system or data source. Pure in-memory implementations, test doubles,
and policy rules are dependencies but not connectors. Invokta’s optional
defineConnector helper validates private construction configuration and
returns named ports, but it does not register, discover, initialize, or expose
connectors through ExecutionContext.
This recipe follows the crawl-engine example, which publishes a stable web
crawling contract backed by Firecrawl. The
file naming guide defines the matching
<provider>-<port-role>.ts convention and the placement of ports,
capabilities, domain rules, and composition roots.
Know each responsibility
Section titled “Know each responsibility”direct / CLI / MCP | v engine.invoke | v capability ---> engine-owned port ---> outbound connector ---> provider| Concept | Responsibility |
|---|---|
| Capability | A public domain action with input and output schemas, access control, a timeout, and execution through engine.invoke. |
| Port | An engine-owned, provider-neutral TypeScript interface describing what a capability needs. |
| Dependency | Any value explicitly passed into a capability or engine factory. Port implementations, domain policies, and permission checkers are all dependencies; only some dependencies are connectors. |
| Outbound connector | A provider- or technology-specific implementation of one or more outbound ports that crosses into an external system or data source. In hexagonal terms, it is an outbound adapter. |
| Connector definition | An optional defineConnector authoring value with private Standard Schema configuration, opaque dependencies, and named ports. It is not an engine registry. |
| Inbound adapter | A delivery boundary such as CLI or MCP. It translates a request and calls engine.invoke; it does not call a connector or capability handler directly. |
| Composition root | Application code that reads deployment configuration, creates connectors and other dependencies, and passes them to the engine factory. |
The naming distinction protects replaceability. Consumers request
crawl.scrape-page; they do not request firecrawl.scrape. The capability can
keep its public contract if Firecrawl is replaced by another WebCrawler.
Build the boundary
Section titled “Build the boundary”-
Declare an engine-owned port
export interface CrawlerCallOptions {readonly signal: AbortSignal;}export interface WebCrawler {scrapePage(request: { readonly target: CrawlTarget },options: CrawlerCallOptions,): Promise<ScrapedPage>;}export interface CrawlDependencies {readonly crawler: WebCrawler;readonly permissions: CrawlPermissionChecker;}Name the port for the domain role, not for the provider or its SDK. Keep provider request, response, client, and authentication types out of this interface. Include
AbortSignalon every operation that can wait for external work. -
Implement the provider connector
export interface FirecrawlWebCrawlerOptions {readonly apiKey: string;readonly baseUrl?: string;readonly pollIntervalMs?: number;readonly maxStatusRequests?: number;readonly maxPaginationRequests?: number;readonly maxResponseBytes?: number;}export function createFirecrawlWebCrawler(options: FirecrawlWebCrawlerOptions,fetchImplementation: typeof globalThis.fetch,): WebCrawler {if (options.apiKey === "") {throw new TypeError("A Firecrawl API key is required.");}return {async scrapePage({ target }, { signal }) {const response = await fetchImplementation(new URL("/v2/scrape", options.baseUrl ?? "https://api.firecrawl.dev"),{method: "POST",headers: { authorization: `Bearer ${options.apiKey}` },body: JSON.stringify({ url: target.url }),signal,},);if (!response.ok) {await response.body?.cancel();throw sanitizedFirecrawlRejection(response.status);}const payload = await readBoundedJson(response, {maxBytes: options.maxResponseBytes ?? 64 * 1024 * 1024,signal,});return parseFirecrawlScrapedPage(payload);},};}export const firecrawlConnector = defineConnector({name: "firecrawl",config: z.object({apiKey: z.string().min(1),baseUrl: z.string().url().optional(),maxResponseBytes: z.number().int().min(1),}),create(config,dependencies: { readonly fetch: typeof globalThis.fetch },) {return {ports: {crawler: createFirecrawlWebCrawler(config,dependencies.fetch,),},};},});defineConnectorseparates private, schema-validated deployment configuration from opaque dependencies such asfetch, an SDK client, a clock, or a pool. The connector must not readprocess.env, perform network I/O during import or construction, or expose its provider client to capabilities.Configuration validation is synchronous. Its transformed value must be a lossless JSON object and is frozen before the connector callback runs. Reject invalid endpoint schemes, embedded endpoint credentials, and numeric limits outside their documented finite safe-integer ranges before any filesystem, network, SDK, or other external I/O begins.
Here
readBoundedJsonincrementally enforces the byte limit and decodes JSON without retaining the raw body in an error. TheparseFirecrawlScrapedPagehelper validates provider fields and translates them into the port-ownedScrapedPagevalue. Both helpers stay private to the connector. -
Depend on the port from the capability
export function createScrapePage({crawler,permissions,}: CrawlDependencies) {return defineCapability({title: "Scrape page",description:"Fetch one public web page and return its main content as Markdown.",input: z.object({ url: crawlTargetUrl }),output: scrapedPageOutput,access: async ({ principal, input }) => {if (principal === null) return false;const target = parseCrawlTarget(input.url);return target !== null &&permissions.can(principal, "crawl:scrape", target);},timeoutMs: 60_000,async run({ input, context }) {return crawler.scrapePage({ target: requireCrawlTarget(input.url) },{ signal: context.signal },);},});}The capability owns its public schemas, authorization, timeout, and domain result. It receives the narrow port it needs, not a provider SDK or a connector registry. Pass
context.signalthrough without creating a second invocation path. -
Translate failures without hiding cancellation
try {return await fetchFromFirecrawl(request, signal);} catch {if (signal.aborted) throw signal.reason;throw new EngineError({code: "EXECUTION_FAILED",message: "The Firecrawl request could not be completed.",publicDetails: { provider: "firecrawl" },cause: new Error("The Firecrawl transport request failed."),});}Provider rejection, malformed data, and network failure should become a sanitized
EXECUTION_FAILEDerror when callers need a stable public failure. Allowlist fields such as a provider name or HTTP status inpublicDetails; never retain credentials, URLs containing secrets, or unfiltered request and response bodies. A retained internalcausemust also be sanitized; its private status is not permission to keep secrets or an unbounded provider payload.When the signal is aborted, stop polling, delays, SDK work, and HTTP requests immediately and rethrow the cancellation cause. Do not wrap it as a provider failure. The Invokta pipeline maps observed cancellation and capability timeout to
CANCELLEDconsistently for direct, CLI, and MCP callers. -
Compose the connector explicitly
export function createCrawlEngine(dependencies: CrawlDependencies) {return createEngine({name: "crawl-engine",version: "0.1.0",capabilities: {"crawl.scrape-page": createScrapePage(dependencies),"crawl.map-site": createMapSite(dependencies),"crawl.crawl-site": createCrawlSite(dependencies),},});}export function createFirecrawlCrawlEngine(environment: FirecrawlEnvironment = process.env,) {const apiKey = environment.FIRECRAWL_API_KEY;if (apiKey === undefined || apiKey === "") {throw new Error("FIRECRAWL_API_KEY is required.");}const connector = firecrawlConnector.create({apiKey,maxResponseBytes: 64 * 1024 * 1024,},{ fetch: globalThis.fetch },);return createCrawlEngine({crawler: connector.ports.crawler,permissions: createAttributeCrawlPermissionChecker(),});}The composition root translates deployment configuration into explicit connector options. Credentials never become capability input, output,
ExecutionContext, principal attributes, events, or public errors. Each direct, CLI, and MCP entrypoint uses the same engine factory and reaches the same capability implementation throughengine.invoke.
Implement several ports with one connector
Section titled “Implement several ports with one connector”One connector definition may provide several related ports when they share a cohesive provider configuration and transport boundary:
const hubspotConnector = defineConnector({ name: "hubspot", config: hubspotConfigSchema, create(config, dependencies: HubSpotDependencies) { const client = createHubSpotClient(config, dependencies.fetch); return { ports: { customers: createHubSpotCustomerDirectory(client), deals: createHubSpotDealRepository(client), activities: createHubSpotActivityWriter(client), }, }; },});
const hubspot = hubspotConnector.create( { accessToken }, { fetch: globalThis.fetch },).ports;const engine = createSupportEngine({ customers: hubspot.customers, deals: hubspot.deals,});The bundle is a composition convenience, not a service locator. Pass each capability only the ports it needs. Keep separate connectors separate when they have different ownership, configuration, lifecycle, or failure boundaries.
Make operational limits explicit
Section titled “Make operational limits explicit”Invokta supplies the capability timeout and cancellation signal, but it does not supply connector lifecycle, retries, concurrency control, queues, or provider limits. Define those decisions in the custom engine:
- Bound public input and output sizes in capability schemas.
- Bound response bytes, page counts, pagination, polling attempts, batch sizes, concurrency, and fan-out where the connector could otherwise consume unbounded resources.
- Reject pagination and redirect targets that leave the configured provider origin unless the integration explicitly requires and validates them.
- Give every connector-backed capability a finite
timeoutMs. A provider request timeout may shorten that deadline but does not replace it. Propagate the invocation signal through every nested provider operation. - Give connector work performed during
accessits own finite client or operation deadline. CapabilitytimeoutMsstarts after authorization and therefore cannot bound an authorization-stage connector call. - Do not retry by default. If a real use case needs retries, make the policy explicit, cancellation-aware, and safe for the operation’s idempotency semantics. Bound both the attempt count and the delay or elapsed retry time, including retries performed by a provider SDK.
- Let the engine host own SDK clients, connection pools, credential rotation, startup, and shutdown. Do not add lifecycle hooks to Invokta context or core.
Provider documentation may describe higher limits, but the engine should expose only the smaller, deliberate bounds it can validate and operate safely.
Test both sides of the port
Section titled “Test both sides of the port”Test the capability with a small port double. This proves domain behavior without starting the provider or transport:
const crawler: WebCrawler = { async scrapePage({ target }, { signal }) { observedSignal = signal; return { url: target.url, title: "Example", statusCode: 200, markdown: "# Example", }; },};
const engine = createCrawlEngine({ crawler, permissions });await engine.invoke("crawl.scrape-page", { url: "https://example.com/" });Test the connector separately against a local protocol stub or injected client. Cover at least:
- provider request construction and domain response translation;
- provider rejection and sanitized
EXECUTION_FAILEDdetails; - malformed or oversized provider responses;
- cancellation during every wait the connector implements, such as the initial request, response-body read, retry delay, polling, and pagination;
- inclusive and exclusive polling, page, batch, and response-size limits;
- redirect and pagination target validation where applicable;
- credential redaction from errors, logs, events, snapshots, and test output;
- synchronous rejection of missing or invalid construction configuration, including exact numeric boundaries and unsafe endpoint URLs;
- construction without external I/O and tests without production credentials.
Finally, use entrypoint tests to prove direct, CLI, MCP stdio, and MCP HTTP all reach the same engine invocation. The connector does not need one implementation per inbound adapter.
What not to build
Section titled “What not to build”Do not add any of these to provide a connector:
createEngine({ connectors: [...] })or a connector field in the core API;context.connectors,engine.getConnector(), or another service locator;- global registration, decorators, reflection, or package-name discovery;
- connector-specific capability IDs that merely mirror a provider API;
- framework-managed initialization, health checks, retries, or shutdown;
- provider request or SDK types in capability input, output, or port contracts.
Caller-authentication verifiers and MCP HTTP authenticate hooks remain inbound
host-boundary integrations, not capability connectors. Client products that call
installed MCP servers “connectors” use a separate product concept.
Those designs either bypass the stable domain capability or introduce runtime facilities that Invokta intentionally leaves to the custom engine.
Run it
Section titled “Run it”Provide a Firecrawl API key, build the repository, and invoke one public URL:
export FIRECRAWL_API_KEY='fc-...'yarn buildnode examples/crawl-engine/dist/direct.js https://example.com/The same capability is available through the CLI:
node examples/crawl-engine/dist/cli.js run crawl.scrape-page \ --input '{"url":"https://example.com/"}'The bundled local principal allows example.com and firecrawl.dev. Change the
trusted principal configuration before using another host.
Verify it
Section titled “Verify it”The tests use a local Firecrawl-compatible stub and never call the public API, so they do not require a real credential:
yarn workspace @invokta/example-crawl testyarn workspace @invokta/example-crawl typecheckyarn workspace @invokta/example-crawl buildRead the complete
crawl-engine
example for target validation, provider payload checks, bounded polling,
cancellation, authorization, and all four inbound entry points.
The repository also demonstrates the same typed construction boundary in
image-engine
for a cohesive multi-port provider, in
observability-engine
for three independent providers, and in the
obsidian-context-engine
and
agent-session-engine
for read-only and durable filesystem connectors.