> ## Documentation Index
> Fetch the complete documentation index at: https://docs.beta.adapter.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Connectors

> Push your own data into Adapter from any source

Custom connectors allow you to integrate data from sources not supported by our native connectors, or when you need to manage the connection and ingestion process yourself.

## When to use one

Reach for a custom connector when:

* **You want to manage the connection yourself.** Stand up your own pipeline against any source — including ones we support natively — and forward the same first-party event shapes (`email`, `page`, `message`, ...) to Adapter via [`/ingest/typed`](#typed-events-recommended). Downstream processing — entity extraction, relationship resolution, the whole pipeline — runs identically.
* **The source isn't on the [supported providers](/core-concepts/providers) list.** Internal tools, legacy systems, anything you can hit with HTTPS — push it as a typed kind if it fits one of our shapes, otherwise as `custom`.
* **You want to mix typed first-party data with arbitrary records** that Adapter doesn't otherwise see — system logs, ticket metadata, audit events. One connector can emit any combination of kinds it's configured to accept.

## Creating a connector

<Steps>
  <Step title="Open the Console">
    Go to **Connectors** in [mind.adapter.com](https://mind.adapter.com) and switch to the **Custom** tab. Click **Create connector**.
  </Step>

  <Step title="Name it">
    Give it a display name and a **source slug** (auto-derived from the name, lowercase + hyphens). The slug is immutable after creation — it shows up in the event source (`custom:your-slug`) and the storage path. Pick something descriptive.
  </Step>

  <Step title="Pick accepted kinds">
    Select which first-party event types this connector may emit (`email`, `page`, `message`, `calendar`, `issue`, or generic `custom`). Selecting none means *all* kinds are accepted. Anything outside the list is rejected with a 400.
  </Step>

  <Step title="Save the connector ID">
    The console shows the `connector_id`. Copy it — you'll need it in the ingest endpoint paths below.
  </Step>

  <Step title="Create an API key">
    Create an API key under **Settings → API keys** and store it securely — the secret is shown only once. See [API keys](/getting-started/api-keys) for the full flow. Use this `pk_live_…` key as the bearer token when calling the ingest endpoints.
  </Step>
</Steps>

## Pushing data

Each connector exposes four ingest endpoints under `/v1/custom-connectors/{connector_id}`. All require `Authorization: Bearer pk_live_…`.

### Typed events (recommended)

Use the typed path when your data fits one of Adapter's first-party shapes (`email`, `page`, etc.). Adapter applies the same processing as native connectors — entity extraction, relationship resolution, the works.

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest/typed" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "kind": "email",
        "payload": {
          "sender": "alice@acme.com",
          "subject": "Q2 review",
          "body_snippet": "Numbers look good."
        }
      }
    ]
  }'
```

The response echoes an `external_ids` list — useful if you want to look up the resulting document later.

### Generic events

When your data doesn't fit a typed shape — free-text notes, log lines, custom records from a tool that doesn't have an Adapter integration — use the generic `/ingest` endpoint. It stores the `data` field verbatim as a `StandardCustom` event with `event_type: "adapter.data.custom"`.

**Free text:**

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "data": { "text": "Quick note from the standup: we shipped the migration." } }
    ]
  }'
```

The `data` field accepts any JSON value — it's stored as-is. There's no required shape, but using a stable key (e.g. `text`, `body`, `content`) makes downstream queries easier.

**Custom record with structure:**

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "external_id": "ticket-9182",
        "data": {
          "title": "Customer reported login loop",
          "severity": "high",
          "tags": ["auth", "regression"],
          "reported_by": "casey@acme.com"
        }
      }
    ]
  }'
```

**Batch:** post multiple items in a single request — Adapter accepts up to a few hundred per call.

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "external_id": "log-001", "data": { "text": "user signed in" } },
      { "external_id": "log-002", "data": { "text": "password reset requested" } },
      { "external_id": "log-003", "data": { "text": "two-factor enabled" } }
    ]
  }'
```

<Note>
  LLM enrichment **does not run** on `/ingest` (JSON-only). If you have a long blob of text and want Adapter to extract summary/key-facts from it, save it to a file and post via [`/ingest/upload`](#binary-uploads) — the enricher picks up text-based documents, PDFs, images, and Office docs.
</Note>

### Binary uploads

For files (PDFs, images, Office docs, and text-based documents), use the multipart upload endpoint:

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest/typed/upload" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -F "file=@/path/to/contract.pdf" \
  -F "kind=page" \
  -F 'payload={"title":"Q2 contract"}'
```

Adapter stores the bytes and runs LLM enrichment automatically — supported types include PDFs, images (PNG/JPEG/GIF/WebP), Office files (.docx/.xlsx/.pptx), and text-based documents. The resulting document carries an `enriched_content` block with summary, extracted text, and key facts. Use `/ingest/upload` (without `/typed/`) for binaries that don't map to a typed kind.

## Typed event shapes

The `payload` you send to `/ingest/typed` is validated against the corresponding type schema. Fields like `source`, `user_id`, `container_id`, and `event_type` are filled in automatically from the connector — you only supply the fields below. `external_id` is optional everywhere; Adapter generates one if you omit it. `timestamp` (ISO 8601) is also optional and defaults to ingest time.

### `email`

| Field          | Type   | Required | Notes                                                                                                |
| -------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------- |
| `sender`       | string | ✓        | Display name or address.                                                                             |
| `subject`      | string | ✓        |                                                                                                      |
| `body_snippet` | string |          | First few hundred chars of the body. Larger content goes through enrichment if attached as a binary. |

### `page`

For documents — Notion pages, Confluence pages, internal wikis, PDFs uploaded as `kind=page`.

| Field             | Type     | Required | Notes                              |
| ----------------- | -------- | -------- | ---------------------------------- |
| `title`           | string   | ✓        |                                    |
| `content_snippet` | string   |          |                                    |
| `url`             | string   |          | Canonical link back to the source. |
| `parent_id`       | string   |          | Parent page/folder identifier.     |
| `parent_type`     | string   |          | e.g. `"folder"`, `"page"`.         |
| `created_by`      | string   |          |                                    |
| `last_edited_by`  | string   |          |                                    |
| `last_edited_at`  | datetime |          | ISO 8601.                          |

### `message`

For chat / Slack-like messages.

| Field        | Type    | Required | Notes                                 |
| ------------ | ------- | -------- | ------------------------------------- |
| `channel_id` | string  | ✓        |                                       |
| `sender`     | string  | ✓        |                                       |
| `text`       | string  | ✓        |                                       |
| `is_direct`  | boolean | ✓        | `true` for DMs, `false` for channels. |
| `thread_ts`  | string  |          | Thread parent timestamp, if replying. |

### `calendar`

| Field        | Type     | Required | Notes                   |
| ------------ | -------- | -------- | ----------------------- |
| `title`      | string   | ✓        |                         |
| `start_time` | datetime |          | Use for timed events.   |
| `end_time`   | datetime |          |                         |
| `start_date` | date     |          | Use for all-day events. |
| `end_date`   | date     |          |                         |

Provide either the `_time` pair (timed) or the `_date` pair (all-day), not both.

### `issue`

For tickets — Linear, Jira, GitHub issues, internal tracker rows.

| Field         | Type    | Required | Notes                                       |
| ------------- | ------- | -------- | ------------------------------------------- |
| `title`       | string  | ✓        |                                             |
| `number`      | integer |          | Display number (e.g. `ENG-1234` → `1234`).  |
| `description` | string  |          |                                             |
| `status`      | string  |          | Free-form (e.g. `"open"`, `"in-progress"`). |
| `assignee`    | string  |          |                                             |
| `priority`    | integer |          |                                             |
| `labels`      | array   |          | List of label strings.                      |
| `url`         | string  |          |                                             |
| `action`      | string  |          | One of `"create"`, `"update"`, `"remove"`.  |

### `custom`

Escape hatch for data that doesn't fit any typed shape. The whole `data` payload is stored verbatim.

| Field  | Type   | Required | Notes     |
| ------ | ------ | -------- | --------- |
| `data` | object |          | Any JSON. |

You can also send `custom` via the simpler `/ingest` endpoint (no `kind` field required) — see [Generic events](#generic-events) above.

## Event metadata

Every event carries a `metadata` field — a flat **string-to-string** key/value map. Set it in two places; the two get merged at ingest time.

### Connector-level metadata

Set `metadata` on the connector to apply it to **every** event the connector emits. Useful for source-environment markers (`env`, `region`, `tenant`), data lineage (`pipeline`, `version`), or anything that's constant for the connector's lifetime.

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme CRM",
    "source_name": "acme-crm",
    "accepted_kinds": ["page"],
    "metadata": { "env": "staging", "pipeline": "acme-crm@v2" }
  }'
```

Update later with `PATCH /v1/custom-connectors/{connector_id}` — changes apply to events ingested **after** the patch lands. Previously ingested events keep the metadata they had at the time.

### Per-item metadata

Set `metadata` on an individual ingest item to apply it to that event only. Useful for per-batch markers, request-correlation IDs, or anything that varies between calls.

```bash theme={null}
curl -X POST "https://api.adapter.com/v1/custom-connectors/$CONNECTOR_ID/ingest/typed" \
  -H "Authorization: Bearer $PK_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {
        "kind": "page",
        "external_id": "doc-001",
        "payload": { "title": "Q2 plan" },
        "metadata": { "batch": "backfill-2026-06-01", "src": "legacy-export" }
      }
    ]
  }'
```

Supported on both `/ingest` and `/ingest/typed`.

### Shape

Keys and values are strings. Non-string values are rejected at the API boundary. If you want to express a label-like marker without a meaningful value, use the key as the label and `"true"` as the value (e.g. `{ "urgent": "true" }`). This shape keeps the field cleanly queryable once retrieval-side filtering ships.

### Merge rules

When both are present, **per-item metadata overrides connector metadata on key conflict**. Disjoint keys merge.

| Connector `metadata`                     | Item `metadata`     | Resulting `event.metadata`            |
| ---------------------------------------- | ------------------- | ------------------------------------- |
| `{ "env": "staging" }`                   | `{ "batch": "b1" }` | `{ "env": "staging", "batch": "b1" }` |
| `{ "env": "staging", "team": "growth" }` | `{ "env": "prod" }` | `{ "env": "prod", "team": "growth" }` |
| *absent*                                 | *absent*            | *absent* (no `metadata` field)        |

### Where metadata is visible

The merged map lands on `event.metadata` — a top-level field on every event. That matters because **the event's `raw` blob is stripped before delivery to subscribers and trigger routing**, but `metadata` is not. Practically:

* **Trigger rules and webhook subscribers** see `metadata` on the payload they receive.
* **Stored canonical documents** (in object storage) carry the full `metadata`.
* **Retrieval via `/v1/knowledge/search` and `/v1/knowledge/ask`** — pass a `metadata` object to restrict results to evidence carrying ALL the given key/value pairs (AND-of-exact-match). Max 20 keys, string values only. The same filter applies to `/ask`: it scopes every search the agent runs and rejects out-of-scope document reads, so the answer can only be grounded in matching evidence.

```json theme={null}
POST /v1/knowledge/search
{
  "q": "pricing",
  "metadata": { "env": "staging", "team": "growth" }
}
```

## Looking at ingested data

Each connector card in the console has a **View Data** button that expands to show document counts per resource. Counts update as data lands; if they ever drift, the recovery path is to re-ingest or contact support.

## Tips

* **`external_id`** is optional — Adapter generates one if you omit it. Provide your own when you want idempotent re-ingest (re-posting the same `external_id` overwrites the document).
* **API key scope**: `pk_live_…` keys are tenant-scoped — one key works against any connector in your container. Rotate by creating a new key and revoking the old.
* **Deactivation** stops new ingest but retains existing data.

## What's next

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Full schema for the ingest endpoints.
  </Card>

  <Card title="Evidence types" icon="layer-group" href="/core-concepts/evidence">
    The shape of typed first-party events.
  </Card>
</CardGroup>
