Wrap an external provider
Use an outbound adapter when a capability depends on an external API. The capability should describe the domain operation, while one infrastructure module owns authentication, transport, provider payloads, and error translation.
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.
Build the boundary
Section titled “Build the boundary”-
Declare an engine-owned provider port
export interface WebCrawler {scrapePage(request: { readonly target: CrawlTarget },options: { readonly signal: AbortSignal },): Promise<ScrapedPage>;}export interface CrawlDependencies {readonly crawler: WebCrawler;readonly permissions: CrawlPermissionChecker;}Keep provider request and response types out of this interface. A different crawler can implement the same domain operation later.
-
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 validation, authorization, the timeout, and the public output. The adapter receives the invocation’s cancellation signal.
-
Translate provider failures at the adapter boundary
if (!response.ok) {throw new EngineError({code: "EXECUTION_FAILED",message: "Firecrawl rejected the request.",publicDetails: {provider: "firecrawl",status: response.status,},});}Keep the raw response body and credentials out of
publicDetails. The full adapter also validates provider payloads, bounds polling, and rejects cross-origin pagination targets. -
Read credentials at the composition root
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.");}return createCrawlEngine({crawler: createFirecrawlWebCrawler({ apiKey }),permissions: createAttributeCrawlPermissionChecker(),});}The credential is deployment configuration. It never becomes capability input, output, or trusted invocation context.
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.