# Connect your back office

A data connector is a read-only API of your system that serves the card of an account and a few lists (views). Lelemon calls it signed, shows what comes back and gives the same data to the support copilot as tools. Nothing you return is stored: only who looked is recorded.

Every integrator has a different back office, so Lelemon knows nothing about yours. The format is the contract.


**You will:** Serve account cards from your back office, register the connector and give the copilot read access.

**You need:**
- An https API of your system that can follow the lelemon-connector/v1 format
- A key with connectors:write

**The data connector**

The copilot looks up an account in your back office through Lelemon. The card comes back masked, nothing is stored, and revealing a value leaves a row in the audit.

1. The copilot uses the tool that finds an account by email.
2. Lelemon calls your connector, signed in Lelemon-Signature with its own domain, valid for 60 seconds.
3. Your system answers with the matches. The stable id is what you keep as the externalRef of the contact.
4. Lelemon asks for the card of that account.
5. The card is a list of sections. You mark personal data with sensitive: true and special category data with specialCategory: true.
6. Lelemon masks sensitive fields and anything shaped like a RUT, email, phone, card or secret. Nothing is stored.
7. The copilot reads the card. The tools of a connector are noPersist: traces keep only status and duration.
8. To see one value, a key with connectors:reveal calls reveal for that field. Fields marked specialCategory cannot be revealed.
9. The access is recorded with the key or member, the connector, the view and the field. The account is a keyed hash and no value is stored. List the rows with GET /v2/data-connectors/{id}/accesses.

## What you serve

Four documents, all JSON, all under your base URL. Download the JSON Schemas with `GET /v2/data-connectors/format`. The same four requests are declared in the `webhooks` section of `openapi.json` as `connector.manifest`, `connector.accounts.search`, `connector.accounts.retrieve` and `connector.views.read`, with the same schemas as components (`ConnectorManifest`, `ConnectorAccountSearch`, `ConnectorAccount`, `ConnectorView`), so the SDK generates the types of your answers.

| Route | Returns |
|---|---|
| `GET {base}/manifest` | `format: "lelemon-connector/v1"`, your name, how accounts are looked up and the views, each with a name, a description and an input schema written for a model. |
| `GET {base}/accounts/{id}` | The card: `sections` of `field`, `table`, `badge` and `action` components. |
| `GET {base}/accounts?email=` | A list of `{ id, title, subtitle? }`. The email is only for searching: store the stable `id` as the `externalRef` of the contact. |
| `GET {base}/views/{viewId}?cursor=&limit=&name=value` | `{ object: "list", columns, data, hasMore, nextCursor }`. |

Rules the validator enforces:

- A field has a `valueType`: `text`, `number`, `money`, `date`, `status` or `link`. Mark personal data with `sensitive: true`; mark health or other special category data with `specialCategory: true`.
- An `action` is an https link. Its host must be your base URL host or one of the `allowedOrigins` of the connector. Lelemon never calls it: the person opens it in a new tab.
- Account and view ids match `[A-Za-z0-9._~-]{1,128}` (view ids `[a-z][a-z0-9_]{0,39}`). The cursor is opaque and never a URL.
- Unknown properties are dropped, never forwarded. A change that breaks the format ships as `lelemon-connector/v2`; both are served.
- Limits: 8 seconds per call, 1 MB per response, 20 sections, 50 components per section, 200 rows, 30 columns.

## Build it with the SDK

The TypeScript SDK has types and helpers to build the four documents, a validator that reads the same JSON Schemas as `GET /v2/data-connectors/format`, and a `lelemon` command. The SDK installs as described in [TypeScript SDK](/tools/sdk), and the `lelemon` command is described in [Command line](/tools/cli).

```ts title="Node.js"
import { connector } from '@lelemon-ai/sdk';

const manifest = connector.manifest({
  name: 'Acme back office',
  lookup: { byId: true, byEmail: true },
  views: [{ id: 'invoices', name: 'Invoices', description: 'Open invoices of an account.' }],
});

const account = connector.account({
  id: 'acc_1',
  title: 'Acme SpA',
  sections: [
    {
      title: 'Plan',
      components: [
        connector.field({ key: 'plan', label: 'Plan', valueType: 'text', value: 'Pro' }),
        connector.badge({ label: 'Active', tone: 'success' }),
      ],
    },
  ],
});

const invoices = connector.viewPage({
  columns: [{ key: 'total', label: 'Total', valueType: 'money' }],
  data: [{ id: 'inv_1', cells: { total: 19990 } }],
});
```


`connector.manifest`, `connector.account`, `connector.field`, `connector.table`, `connector.badge`, `connector.action`, `connector.viewPage` and `connector.accountList` fill the fixed parts (`format`, `object`, `type`, `hasMore`, `nextCursor`). `validateConnectorDocument(type, document)` returns the problems of a document with the same paths as the API; `type` is `manifest`, `account`, `accountSearch` or `view`. The SDK repository has a complete connector of about 50 lines that verifies the signature and serves these documents, at `packages/sdk-ts/examples/connector.ts`.

Check a file or a running connector from the terminal:

```bash title="Terminal"
lelemon connector validate ./manifest.json
lelemon connector validate https://admin.example.com/lelemon --account acc_1 --header "Authorization: Bearer ..."
```


With a file, it detects which document it is (or take `--type`). With a URL, it reads `/manifest`, every view of the manifest and, with `--account`, that card. It prints `ok` or each failing path and reason, and exits `1` if anything fails. This is the schema check; the API also checks the hosts of the `action` links, so run `POST /v2/data-connectors/format/validate` once before you register.

To try a connector without writing a server, describe it in a fixture (`manifest`, `accounts` with an optional `email` to search by, and `views` by id) and serve it:

```bash title="Terminal"
lelemon connector serve --fixture ./fixture.json --port 4300
lelemon connector validate http://127.0.0.1:4300 --account acc_1
```


The fixture is validated when the command starts, and a broken one stops it with the failing paths. The server listens on `127.0.0.1` and does not check signatures unless you pass `--secret <signing secret>`. `packages/sdk-ts/examples/fixture.json` is a working one.

## Verify that a call is from Lelemon

Every call carries `Lelemon-Signature` and the turn context headers. The signature covers the method, the host, the path and query and the actor, uses its own domain and expires in 60 seconds. Verify it with the SDK:

```ts title="Node.js"
import { verifyRequest, createReplayGuard } from '@lelemon-ai/sdk';

const isReplay = createReplayGuard();
const { context } = await verifyRequest(req.headers, 'GET', fullUrl, '', secrets, {
  domain: 'connector',
  isReplay,
});
```


Read `secrets` with `GET /v2/data-connectors/{id}/signing-secret` (scope `connectors:write`). After a rotation the previous secret keeps signing for 24 hours: pass the whole list.

## Check a document before you register

`POST /v2/data-connectors/format/validate` takes a manifest, an account card, an account search answer or a view page and answers every place that does not follow the format, with its path and reason. It runs the validator that checks the real answers of your system, stores nothing and calls nothing.

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/data-connectors/format/validate \
  -H "Authorization: Bearer $LELEMON_API_KEY" -H "Content-Type: application/json" \
  -d '{ "type": "account", "baseUrl": "https://admin.example.com/lelemon", "document": { "object": "account", "id": "acc_1", "title": "Acme", "sections": [] } }'
```


`type` is `manifest`, `account`, `accounts_search` or `view_page`. For `account`, send `baseUrl` or `allowedOrigins` so the hosts of the `action` links can be checked. `POST /v2/data-connectors/{id}/check` does the same for the manifest that your registered system really serves.

## Register and check it

A connector is this object:

**DataConnector**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `data_connector` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `slug` | string | yes | The prefix of the copilot tools of this connector, for example backoffice_account. |
| `origin` | string | yes | Only the origin of the base URL. The full URL never comes back. |
| `allowedOrigins` | string[] | yes |  |
| `format` | enum | yes | `lelemon-connector/v1` |
| `status` | enum | yes | pending: created, never checked. active: the last check passed. error: the last check or the manifest failed, see lastError. `pending`, `active`, `error` |
| `lastError` | enum | yes | `unavailable`, `invalid_response`, `not_found`, `bad_request`, `null` |
| `viewCount` | integer | yes | Views in the stored manifest. |
| `manifestFetchedAt` | string or null | yes |  |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

```bash title="cURL"
curl -X POST https://api.lelemon.cl/v2/data-connectors \
  -H "Authorization: Bearer $LELEMON_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "Backoffice", "baseUrl": "https://admin.example.com/lelemon" }'

curl -X POST https://api.lelemon.cl/v2/data-connectors/dcn_4Zq0x8bVw1TqS9mYp3RkLd/check \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```


The base URL must be https, public and inside the allowed sites of your key. An `http` URL, one with a user or password, or one that resolves to a private address answers `422 action_destination_blocked` and nothing is saved. The connector starts `pending`. The check calls your manifest, validates it and, if it passes, makes the connector `active`. A manifest that does not follow the format answers `ok: false` with the failing paths, never the values. The check reads the manifest only. An account card, a search answer or a view page is validated when it is read: a read whose answer does not follow the format answers `422 data_connector_invalid_response`, and its `detail` names the failing paths and the rule each one breaks, never the values. Test those documents before serving them with `POST /v2/data-connectors/format/validate`.

## Masked by default

Fields marked `sensitive`, and any value shaped like a RUT, email, phone, card or secret, come back with `value: null`, `masked: true` and a `maskReason`. The same happens in titles, labels and table cells. The detection by shape is a fallback: mark your sensitive data yourself.

To see one value, call `POST /v2/data-connectors/{id}/accounts/{accountId}/reveal` with a key that has `connectors:reveal`. Fields marked `specialCategory` cannot be revealed.

## In the contact page

A contact whose `externalRef` is the id of an account shows that account in its page of the dashboard, read live from each active connector with the same masking and the same reveal as above. It is for owners and admins, nothing is stored and the reads are recorded in the audit. When your system fails, the page says why and the rest of the contact keeps working. See [Contacts](/guides/contacts#the-account-in-your-system).

## Audit

Every read, reveal and action click is recorded with the key or member, the connector, the view and the field. The account is stored as a keyed hash and no value is ever stored. Rows are signed, kept for two years and listed with `GET /v2/data-connectors/{id}/accesses` (scope `connectors:audit-read`); a row that no longer matches its signature shows `integrity: "tampered"`.

## The copilot

An active connector gives agents of the internal kind three kinds of tools: find an account by email, read a card and read each view. Create the copilot with `POST /v2/agents` and `audience: "internal"`, which needs a key with `agents:internal` and cannot come from a template. Then list its tools with `GET /v2/agents/{id}/tools` and grant them with `PUT /v2/agents/{id}/tools/{toolId}` (ids start with `dct_`). Open a case with it by sending its `agentId` and the `pipelineId` or `stageId` where the case lives; a copilot belongs to no stage, so the case needs a place of its own. Write to the case with `author: "contact"` and the copilot answers on the case like any agent, with the same messages and events, and the case shows in the dashboard. The copilot answers the people of your team, so it cannot open email cases and it cannot be linked to a stage. The assistant of the owner is not reachable by the API, and a copilot of another organization answers `404`.

Once a turn has read data of your connector, the copilot gets no tool that writes or that calls another system for the rest of that turn: it can keep reading from the connector and answer. A tool it asks for anyway is not run and the model is told why. This is the defense against an instruction hidden in your data; the next turn has all its tools again. They are marked `noPersist`: the result lives only in the turn, and traces keep only status and duration. The content does pass through the prompt to the model provider, so do not mark as non-sensitive what must not leave your system.
