Skip to content

Add a connector

Teaching the platform to read a system it does not support yet.

Two different jobs live under that sentence, and they cost very different amounts. Decide which one you are doing before you start.

Job Effort Where it ends
Add a tool to a connector that already exists Minutes One list, in one file
Add a whole new connector A day Provider module, catalog, API validation, dashboard wizard

Part one: a new tool on an existing connector

This is where most work should go, and it really is one function.

A connector exposes its investigation surface from a single tools() function returning an array. Each entry is a name, a description, an input schema, and a body.

{
  name: 'list_tests',
  description:
    'List checks of a given type with their status. type is one of uptime, ssl, pagespeed, ' +
    'heartbeat. Paginated: optional page/per_page.',
  inputSchema: z.object({
    type: z.enum(TEST_TYPES),
    page: z.number().int().positive().optional(),
  }),
  run: async (input) => { /* one read, returning plain data */ },
}

Adding an entry to that array is the whole change. Binding to the engine, per-tenant scoping, redaction, auditing, and the published documentation table all happen above this layer and apply to every tool uniformly.

The description is not decoration. It is the only thing the model reads when deciding whether this tool answers the question in front of it. Say what it returns, what the inputs mean, and what the result cannot tell you.

Three rules the surrounding code already assumes:

  • Reads only. Never expose a call that changes the other system.
  • Validate anything interpolated into a path or a query. Tool input is model output, which is ultimately influenced by whatever text arrived in the incident.
  • Return data, not prose. The engine does its own summarising, and paragraphs cost tokens.

Run bun run docs:gen afterwards. The tool table on the connector's page is generated by calling your factory and reading back what it registers, so the page updates itself.

Part two: a whole new connector

1. Declare the type

Add the identifier to CONNECTOR_TYPE_IDS in packages/connectors/src/types.ts. It is a const tuple, so the new value immediately joins the ConnectorType union and the compiler starts pointing at everything that now needs a branch. Follow it.

2. Create the provider folder

packages/connectors/src/data-sources/<type>/, where the folder name equals the type identifier exactly. It needs an index.ts that re-exports the module.

Size rules, enforced in CI: at most 500 lines per production module, at most 600 per test module, and at least 25 lines unless the file is index.ts, types.ts, or definition.ts. The lower bound exists to stop a connector being scattered across a dozen tiny files.

3. Write the module

The shape below is the whole contract. Everything except probe is optional.

import * as z from 'zod';
import { createDataSourceConnector, defineConnector, type ConnectorConfig } from '../../registry';
import type { ConnectorTool, IDataSourceConnector, ProbeResult } from '../../types';

// Injectable so the calls are unit-testable without the network.
type FetchLike = typeof fetch;

const EXAMPLE_CONNECTOR = {
  type: 'example',
  capabilities: {
    availability: 'ready',
    configuration: 'tenant',
    instances: 'multiple',
    investigation: 'tools',
    polling: 'none',
    events: 'none',
  },
} as const;

function makeExampleConnector(
  config: ConnectorConfig,
  fetchImpl: FetchLike = fetch,
): IDataSourceConnector {
  return createDataSourceConnector(EXAMPLE_CONNECTOR, config, {
    tools: () => makeExampleTools(config, fetchImpl),
    async probe(): Promise<ProbeResult> {
      // Prove the exact reads the connector will later depend on.
      return { status: 'healthy', reachable: true, authorized: true, warnings: [] };
    },
  });
}

export const exampleConnectorDefinition = defineConnector({
  ...EXAMPLE_CONNECTOR,
  create: makeExampleConnector,
});

The capability block is a contract, not a description. polling: 'snapshots' commits you to a snapshot(); investigation: 'tools' commits you to tools().

Declaring a capability you did not implement does not fail at startup. The registry fills every unimplemented operation with a stub that throws when something calls it, so the failure surfaces later, inside a poll or an investigation. Declare only what you implement.

The factory never receives the credential. It receives a function that resolves it, so the decrypted value exists only for the duration of the call that needs it. Do not hoist it into a closure variable at construction time.

4. Write a probe that actually proves something

probe() is the single most valuable thing you will write, because it decides whether the connection is switched on at all, and it is the operator's only feedback at setup time.

A good probe performs the cheapest read that exercises the same permission the real tools need, and distinguishes the three outcomes an operator can act on:

Outcome reachable authorized Means
Success true true Switch the connection on
Rejected credential true false Their token is wrong, not their address
No response false false Their address or network is wrong

Collapsing the middle row into the last one is the most common mistake here. It sends an operator to check their firewall when their token was simply expired. Set failureCategory when you know which of permission_denied, rate_limited, provider_unavailable, unreachable, or tls applies, and put nothing secret in warnings or details.

5. Register it in the catalog

Add the definition to CONNECTOR_DEFINITIONS in packages/connectors/src/catalog.ts. The catalog asserts at load that every declared type is defined exactly once, so a mismatch fails immediately.

6. Let the guards find your mistakes

Check Catches
The catalog's own load assertion A type declared but not defined, or defined twice
bun run check:connector-architecture A folder name that does not match its type, a missing index.ts, a file outside the size rules
bun run docs:gen --check A connector whose tool list became empty, and a published tool table that no longer matches the code

The tool-table generator calls your real factory with a stub configuration. If your tool list changes shape depending on settings, declare the shapes it should enumerate in the generator's notes file, or it will document only one side of the branch.

7. Wire up setup

This part is not pluggable, and it is worth knowing before you start.

Steps 1 to 6 are purely additive: they touch no existing provider. Setup is different. The API's save route validates settings per connector type with explicit branching, and the dashboard has a hand-written wizard per connector rather than a form generated from a schema.

So a new connector also needs:

  • Settings parsing and validation for the new type on the save path, rejecting anything malformed with a clear message rather than storing it.
  • A test route that runs your probe() so the Verify step can prove access.
  • An entry in the dashboard's connector catalog, and a wizard component for its steps.

That is a deliberate trade rather than an oversight. Each connector asks for genuinely different things, and a generated form would be worst at the part that matters most: telling an operator exactly what to create in the other system, and why. It does mean this half is most of the day.

Model the wizard on the closest existing one. Every wizard ends with the same two steps, Review then Verify, and saving always creates a disabled draft that only a successful probe switches on. Keep that, because the whole guide promises it.

8. Document it

Add a page under Connect your tools following the shape the others use: what it adds, how it is wired, before you start, the steps, and what it can read. Include the generated tool table rather than writing one.

Then add the wizard to the screenshot walk so its steps are captured and checked. See Keeping the docs true.

What a connector may never do

Write. There is no code path in the platform holding a credential that can change a connected system. A connector that adds one breaks a guarantee the product makes on every page.

Add a datastore. PostgreSQL and Valkey are the only two. A connector that wants to cache something uses the existing snapshot cache.

Reach a private address, unless its provider is genuinely self-hosted. The shared guard blocks loopback, link-local, and cloud metadata ranges regardless. A software-as-a-service provider should be host-pinned in code so a tool input can never redirect it somewhere else.